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 repo-relative paths of every file tracked in the git
82    /// index (what `git ls-files` would print), sorted and deduplicated.
83    ///
84    /// Deduplication matters because an unresolved merge conflict produces
85    /// one index entry per stage for the same path.
86    pub fn tracked_files(&self) -> Result<Vec<String>> {
87        let index = self.repo.index().context("Failed to open git index")?;
88        let mut files: Vec<String> = index
89            .iter()
90            .map(|entry| String::from_utf8_lossy(&entry.path).into_owned())
91            .collect();
92        files.sort();
93        files.dedup();
94        Ok(files)
95    }
96
97    /// Returns the repository path.
98    pub fn path(&self) -> &std::path::Path {
99        self.repo.path()
100    }
101
102    /// Returns the workdir path.
103    pub fn workdir(&self) -> Option<&std::path::Path> {
104        self.repo.workdir()
105    }
106
107    /// Returns access to the underlying `git2::Repository`.
108    pub fn repository(&self) -> &Repository {
109        &self.repo
110    }
111
112    /// Returns the current branch name.
113    pub fn get_current_branch(&self) -> Result<String> {
114        let head = self.repo.head().context("Failed to get HEAD reference")?;
115
116        if let Ok(name) = head.shorthand() {
117            if name != "HEAD" {
118                return Ok(name.to_string());
119            }
120        }
121
122        anyhow::bail!("Repository is in detached HEAD state")
123    }
124
125    /// Checks if a branch exists.
126    pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
127        // Check if it exists as a local branch
128        if self
129            .repo
130            .find_branch(branch_name, git2::BranchType::Local)
131            .is_ok()
132        {
133            return Ok(true);
134        }
135
136        // Check if it exists as a remote branch
137        if self
138            .repo
139            .find_branch(branch_name, git2::BranchType::Remote)
140            .is_ok()
141        {
142            return Ok(true);
143        }
144
145        // Check if we can resolve it as a reference
146        if self.repo.revparse_single(branch_name).is_ok() {
147            return Ok(true);
148        }
149
150        Ok(false)
151    }
152
153    /// Resolves the default base branch for commit-range defaults.
154    ///
155    /// Prefers remote-tracking refs so the default range binds to the remote's
156    /// view of the mainline rather than a possibly-stale local branch:
157    /// `origin/main` → `origin/master` → `main` → `master`.
158    /// Returns `None` when none of these refs exist.
159    pub fn resolve_default_base_branch(&self) -> Option<String> {
160        const CANDIDATES: [(&str, git2::BranchType); 4] = [
161            ("origin/main", git2::BranchType::Remote),
162            ("origin/master", git2::BranchType::Remote),
163            ("main", git2::BranchType::Local),
164            ("master", git2::BranchType::Local),
165        ];
166        CANDIDATES
167            .iter()
168            .find(|(name, kind)| self.repo.find_branch(name, *kind).is_ok())
169            .map(|(name, _)| (*name).to_string())
170    }
171
172    /// Parses a commit range and returns the commits.
173    pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
174        let mut commits = Vec::new();
175
176        // Resolved once per invocation; containment is checked per commit.
177        let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
178
179        if range == "HEAD" {
180            // Single HEAD commit
181            let head = self.repo.head().context("Failed to get HEAD")?;
182            let commit = head
183                .peel_to_commit()
184                .context("Failed to peel HEAD to commit")?;
185            commits.push(CommitInfo::from_git_commit(
186                &self.repo, &commit, &main_tips,
187            )?);
188        } else if range.contains("..") {
189            // Range format like HEAD~3..HEAD
190            let parts: Vec<&str> = range.split("..").collect();
191            if parts.len() != 2 {
192                anyhow::bail!("Invalid range format: {range}");
193            }
194
195            let start_spec = parts[0];
196            let end_spec = parts[1];
197
198            // Parse start and end commits
199            let start_obj = self
200                .repo
201                .revparse_single(start_spec)
202                .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
203            let end_obj = self
204                .repo
205                .revparse_single(end_spec)
206                .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
207
208            let start_commit = start_obj
209                .peel_to_commit()
210                .context("Failed to peel start object to commit")?;
211            let end_commit = end_obj
212                .peel_to_commit()
213                .context("Failed to peel end object to commit")?;
214
215            // Walk from end_commit back to start_commit (exclusive)
216            let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
217            walker
218                .push(end_commit.id())
219                .context("Failed to push end commit")?;
220            walker
221                .hide(start_commit.id())
222                .context("Failed to hide start commit")?;
223
224            commits = self.collect_walk(walker, &main_tips, None)?;
225        } else {
226            // Single commit by hash or reference
227            let obj = self
228                .repo
229                .revparse_single(range)
230                .with_context(|| format!("Failed to parse commit: {range}"))?;
231            let commit = obj
232                .peel_to_commit()
233                .context("Failed to peel object to commit")?;
234            commits.push(CommitInfo::from_git_commit(
235                &self.repo, &commit, &main_tips,
236            )?);
237        }
238
239        Ok(commits)
240    }
241
242    /// Walks every commit reachable from `HEAD`, optionally capped to the
243    /// newest `max_count` non-merge commits.
244    ///
245    /// Unlike [`Self::get_commits_in_range`] (which needs an explicit range),
246    /// this is the whole-history default a reporting command like `config
247    /// scopes usage` wants when the caller gave no range at all.
248    pub fn get_commits_from_head(&self, max_count: Option<usize>) -> Result<Vec<CommitInfo>> {
249        let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
250        let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
251        walker.push_head().context("Failed to push HEAD")?;
252        self.collect_walk(walker, &main_tips, max_count)
253    }
254
255    /// Drains a revwalk into commit info, skipping merges, honoring an
256    /// optional count cap (checked after each non-merge commit is collected,
257    /// so it always bounds the output length rather than raw traversal
258    /// steps), then reverses to chronological order (oldest first) — the
259    /// shared collection loop behind [`Self::get_commits_in_range`]'s range
260    /// branch and [`Self::get_commits_from_head`].
261    fn collect_walk(
262        &self,
263        walker: git2::Revwalk<'_>,
264        main_tips: &[crate::git::main_branches::MainBranchTip],
265        max_count: Option<usize>,
266    ) -> Result<Vec<CommitInfo>> {
267        let mut commits = Vec::new();
268
269        for oid in walker {
270            let oid = oid.context("Failed to get commit OID from walker")?;
271            let commit = self
272                .repo
273                .find_commit(oid)
274                .context("Failed to find commit")?;
275
276            // Skip merge commits
277            if commit.parent_count() > 1 {
278                continue;
279            }
280
281            commits.push(CommitInfo::from_git_commit(&self.repo, &commit, main_tips)?);
282
283            if max_count.is_some_and(|n| commits.len() >= n) {
284                break;
285            }
286        }
287
288        // Reverse to get chronological order (oldest first)
289        commits.reverse();
290        Ok(commits)
291    }
292}
293
294/// Formats git status flags into a string representation.
295fn format_status_flags(flags: Status) -> String {
296    let mut status = String::new();
297
298    if flags.contains(Status::INDEX_NEW) {
299        status.push('A');
300    } else if flags.contains(Status::INDEX_MODIFIED) {
301        status.push('M');
302    } else if flags.contains(Status::INDEX_DELETED) {
303        status.push('D');
304    } else if flags.contains(Status::INDEX_RENAMED) {
305        status.push('R');
306    } else if flags.contains(Status::INDEX_TYPECHANGE) {
307        status.push('T');
308    } else {
309        status.push(' ');
310    }
311
312    if flags.contains(Status::WT_NEW) {
313        status.push('?');
314    } else if flags.contains(Status::WT_MODIFIED) {
315        status.push('M');
316    } else if flags.contains(Status::WT_DELETED) {
317        status.push('D');
318    } else if flags.contains(Status::WT_TYPECHANGE) {
319        status.push('T');
320    } else if flags.contains(Status::WT_RENAMED) {
321        status.push('R');
322    } else {
323        status.push(' ');
324    }
325
326    status
327}
328
329impl GitRepository {
330    /// Runs a `git` CLI subcommand in the repository's working directory.
331    ///
332    /// Remote operations shell out to the user's `git` rather than using
333    /// libgit2's network transport so they work across all URL schemes (SSH,
334    /// HTTPS) and honour the user's existing authentication configuration
335    /// (`ssh-agent`, `~/.ssh/config`, credential helpers). The vendored libgit2
336    /// lacks a reliable SSH transport on some platforms. See issue #903.
337    fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
338        let workdir = self
339            .repo
340            .workdir()
341            .context("Cannot run git command: repository has no working directory")?;
342
343        std::process::Command::new("git")
344            .current_dir(workdir)
345            .args(args)
346            .output()
347            .context("Failed to execute git command")
348    }
349
350    /// Pushes the current branch to remote.
351    pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
352        info!(
353            "Pushing branch '{}' to remote '{}'",
354            branch_name, remote_name
355        );
356
357        // Shell out to `git push` so the push works across all URL schemes and
358        // uses the user's configured authentication. `--set-upstream` records
359        // the tracking branch in the same step. See [`Self::run_git`].
360        debug!("Pushing via git CLI to '{}'", remote_name);
361        let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
362
363        if output.status.success() {
364            info!(
365                "Successfully pushed branch '{}' to remote '{}'",
366                branch_name, remote_name
367            );
368            Ok(())
369        } else {
370            let stderr = String::from_utf8_lossy(&output.stderr);
371            let stderr = stderr.trim();
372            error!("Failed to push branch: {}", stderr);
373            anyhow::bail!(
374                "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
375            )
376        }
377    }
378
379    /// Checks if a branch exists on remote.
380    pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
381        debug!(
382            "Checking if branch '{}' exists on remote '{}'",
383            branch_name, remote_name
384        );
385
386        // Query the remote via `git ls-remote` so the lookup works across all
387        // URL schemes and uses the user's configured authentication. See
388        // [`Self::run_git`].
389        debug!("Listing remote refs via git CLI from '{}'", remote_name);
390        let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
391
392        if !output.status.success() {
393            let stderr = String::from_utf8_lossy(&output.stderr);
394            let stderr = stderr.trim();
395            error!("Failed to list remote refs: {}", stderr);
396            anyhow::bail!(
397                "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
398            )
399        }
400
401        // `git ls-remote --heads <remote> <branch>` emits one `<sha>\t<ref>`
402        // line per matching head. The branch argument is a glob pattern that
403        // matches on the ref tail, so compare the ref column exactly to avoid
404        // false positives like `refs/heads/foo/<branch>`.
405        let remote_branch_ref = format!("refs/heads/{branch_name}");
406        let stdout = String::from_utf8_lossy(&output.stdout);
407        let exists = stdout
408            .lines()
409            .filter_map(|line| line.split('\t').nth(1))
410            .any(|reference| reference == remote_branch_ref);
411
412        if exists {
413            info!(
414                "Branch '{}' exists on remote '{}'",
415                branch_name, remote_name
416            );
417        } else {
418            info!(
419                "Branch '{}' does not exist on remote '{}'",
420                branch_name, remote_name
421            );
422        }
423        Ok(exists)
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    // ── format_status_flags ────────────────────────────────────────
432
433    #[test]
434    fn status_flags_new_index() {
435        let status = format_status_flags(Status::INDEX_NEW);
436        assert_eq!(status, "A ");
437    }
438
439    #[test]
440    fn status_flags_modified_index() {
441        let status = format_status_flags(Status::INDEX_MODIFIED);
442        assert_eq!(status, "M ");
443    }
444
445    #[test]
446    fn status_flags_deleted_index() {
447        let status = format_status_flags(Status::INDEX_DELETED);
448        assert_eq!(status, "D ");
449    }
450
451    #[test]
452    fn status_flags_wt_new() {
453        let status = format_status_flags(Status::WT_NEW);
454        assert_eq!(status, " ?");
455    }
456
457    #[test]
458    fn status_flags_wt_modified() {
459        let status = format_status_flags(Status::WT_MODIFIED);
460        assert_eq!(status, " M");
461    }
462
463    #[test]
464    fn status_flags_combined() {
465        let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
466        assert_eq!(status, "AM");
467    }
468
469    #[test]
470    fn status_flags_empty() {
471        let status = format_status_flags(Status::empty());
472        assert_eq!(status, "  ");
473    }
474
475    // ── GitRepository with temp repo ───────────────────────────────
476
477    /// Creates an empty git-inited tempdir anchored at `$CARGO_MANIFEST_DIR/tmp`.
478    ///
479    /// Centralising the setup avoids scattering four copies of the same
480    /// `?`-laced boilerplate across these tests, which also gives codecov a
481    /// single place to attribute coverage for the directory-creation
482    /// machinery.
483    #[allow(clippy::unwrap_used)]
484    fn init_tmp_repo() -> tempfile::TempDir {
485        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
486        std::fs::create_dir_all(&tmp_root).unwrap();
487        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
488        git2::Repository::init(temp_dir.path()).unwrap();
489        temp_dir
490    }
491
492    #[test]
493    fn open_at_temp_repo() -> Result<()> {
494        let temp_dir = init_tmp_repo();
495        let repo = GitRepository::open_at(temp_dir.path())?;
496        assert!(repo.path().exists());
497        Ok(())
498    }
499
500    #[test]
501    fn working_directory_clean_empty_repo() -> Result<()> {
502        let temp_dir = init_tmp_repo();
503        let repo = GitRepository::open_at(temp_dir.path())?;
504        let status = repo.get_working_directory_status()?;
505        assert!(status.clean);
506        assert!(status.untracked_changes.is_empty());
507        Ok(())
508    }
509
510    #[test]
511    fn working_directory_dirty_with_file() -> Result<()> {
512        let temp_dir = init_tmp_repo();
513        std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
514        let repo = GitRepository::open_at(temp_dir.path())?;
515        let status = repo.get_working_directory_status()?;
516        assert!(!status.clean);
517        assert!(!status.untracked_changes.is_empty());
518        Ok(())
519    }
520
521    #[test]
522    fn is_working_directory_clean_delegator() -> Result<()> {
523        let temp_dir = init_tmp_repo();
524        let repo = GitRepository::open_at(temp_dir.path())?;
525        assert!(repo.is_working_directory_clean()?);
526        Ok(())
527    }
528
529    #[test]
530    fn current_branch_on_a_branch() -> Result<()> {
531        let temp_dir = init_tmp_repo();
532        let p = temp_dir.path();
533        std::fs::write(p.join("f.txt"), "x")?;
534        git_in(p, &["add", "."]);
535        git_in(p, &["commit", "-m", "init"]);
536        let repo = GitRepository::open_at(p)?;
537        // The branch name is whichever the local git default is (main/master);
538        // either way it must resolve to a non-"HEAD" shorthand.
539        assert_ne!(repo.get_current_branch()?, "HEAD");
540        Ok(())
541    }
542
543    #[test]
544    fn current_branch_errors_in_detached_head() -> Result<()> {
545        // CI checks PRs out as a detached HEAD, which is what makes the bail at
546        // the end of `get_current_branch` flicker run-to-run; pin it here.
547        let temp_dir = init_tmp_repo();
548        let p = temp_dir.path();
549        std::fs::write(p.join("f.txt"), "x")?;
550        git_in(p, &["add", "."]);
551        git_in(p, &["commit", "-m", "init"]);
552        git_in(p, &["checkout", "--detach", "HEAD"]);
553        let repo = GitRepository::open_at(p)?;
554        let result = repo.get_current_branch();
555        assert!(
556            matches!(&result, Err(e) if e.to_string().contains("detached HEAD")),
557            "expected detached-HEAD error, got: {result:?}"
558        );
559        Ok(())
560    }
561
562    // ── remote operations via the git CLI (issue #903) ─────────────
563
564    /// Runs `git` in `dir` with a deterministic identity, asserting success.
565    #[allow(clippy::unwrap_used)]
566    fn git_in(dir: &std::path::Path, args: &[&str]) {
567        let output = std::process::Command::new("git")
568            .current_dir(dir)
569            .args([
570                "-c",
571                "user.email=test@example.com",
572                "-c",
573                "user.name=Test",
574                // Disable signing so the tests stay hermetic regardless of the
575                // developer's global `commit.gpgsign` / `tag.gpgsign` config —
576                // GPG signing also races under parallel test execution.
577                "-c",
578                "commit.gpgsign=false",
579                "-c",
580                "tag.gpgsign=false",
581            ])
582            .args(args)
583            .output()
584            .unwrap();
585        let stderr = String::from_utf8_lossy(&output.stderr);
586        assert!(output.status.success(), "git {args:?} failed: {stderr}");
587    }
588
589    /// Builds a work repo with one commit on `feature-branch` and a bare
590    /// `origin` remote it can push to. Both temp dirs are returned so the
591    /// caller keeps them alive for the duration of the test.
592    #[allow(clippy::unwrap_used)]
593    fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
594        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
595        std::fs::create_dir_all(&tmp_root).unwrap();
596        let bare = tempfile::tempdir_in(&tmp_root).unwrap();
597        git_in(bare.path(), &["init", "--bare"]);
598
599        let work = init_tmp_repo();
600        std::fs::write(work.path().join("file.txt"), "content").unwrap();
601        git_in(work.path(), &["checkout", "-b", "feature-branch"]);
602        git_in(work.path(), &["add", "."]);
603        git_in(work.path(), &["commit", "-m", "initial"]);
604        git_in(
605            work.path(),
606            &["remote", "add", "origin", bare.path().to_str().unwrap()],
607        );
608
609        let repo = GitRepository::open_at(work.path()).unwrap();
610        (work, bare, repo)
611    }
612
613    #[test]
614    fn branch_absent_on_remote_before_push() -> Result<()> {
615        let (_work, _bare, repo) = repo_with_bare_remote();
616        assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
617        Ok(())
618    }
619
620    #[test]
621    fn push_branch_then_present_on_remote() -> Result<()> {
622        let (_work, _bare, repo) = repo_with_bare_remote();
623        repo.push_branch("feature-branch", "origin")?;
624        assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
625        assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
626        Ok(())
627    }
628
629    #[test]
630    fn branch_exists_requires_exact_ref_match() -> Result<()> {
631        // `git ls-remote <branch>` matches on the ref tail, so a sibling like
632        // `team/feature-branch` would glob-match `feature-branch`. The exact
633        // ref comparison must reject it as a false positive.
634        let (work, _bare, repo) = repo_with_bare_remote();
635        git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
636        repo.push_branch("team/feature-branch", "origin")?;
637        assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
638        assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
639        Ok(())
640    }
641
642    #[test]
643    fn push_branch_reports_failure_for_unknown_remote() {
644        let (_work, _bare, repo) = repo_with_bare_remote();
645        let result = repo.push_branch("feature-branch", "nonexistent");
646        assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
647    }
648
649    #[test]
650    fn branch_exists_reports_failure_for_unknown_remote() {
651        let (_work, _bare, repo) = repo_with_bare_remote();
652        let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
653        assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
654    }
655
656    // ── resolve_default_base_branch (issue #1106) ──────────────────
657
658    #[test]
659    fn resolve_default_base_prefers_origin_main_over_local() -> Result<()> {
660        let (work, _bare, repo) = repo_with_bare_remote();
661        git_in(work.path(), &["branch", "main"]);
662        // Pushing creates `refs/remotes/origin/main` in the work repo, so both
663        // the local and the remote-tracking branch exist; the remote must win.
664        repo.push_branch("main", "origin")?;
665        assert_eq!(
666            repo.resolve_default_base_branch(),
667            Some("origin/main".to_string())
668        );
669        Ok(())
670    }
671
672    #[test]
673    fn resolve_default_base_prefers_origin_master_over_local_main() -> Result<()> {
674        // Interleaved order: a remote-tracking `origin/master` outranks a
675        // (possibly stale) local `main`.
676        let (work, _bare, repo) = repo_with_bare_remote();
677        git_in(work.path(), &["branch", "main"]);
678        git_in(work.path(), &["branch", "master"]);
679        repo.push_branch("master", "origin")?;
680        assert_eq!(
681            repo.resolve_default_base_branch(),
682            Some("origin/master".to_string())
683        );
684        Ok(())
685    }
686
687    #[test]
688    fn resolve_default_base_uses_local_main_without_remote() -> Result<()> {
689        let temp_dir = init_tmp_repo();
690        let p = temp_dir.path();
691        std::fs::write(p.join("f.txt"), "x")?;
692        git_in(p, &["checkout", "-b", "main"]);
693        git_in(p, &["add", "."]);
694        git_in(p, &["commit", "-m", "init"]);
695        let repo = GitRepository::open_at(p)?;
696        assert_eq!(repo.resolve_default_base_branch(), Some("main".to_string()));
697        Ok(())
698    }
699
700    #[test]
701    fn resolve_default_base_falls_back_to_local_master() -> Result<()> {
702        let temp_dir = init_tmp_repo();
703        let p = temp_dir.path();
704        std::fs::write(p.join("f.txt"), "x")?;
705        git_in(p, &["checkout", "-b", "master"]);
706        git_in(p, &["add", "."]);
707        git_in(p, &["commit", "-m", "init"]);
708        let repo = GitRepository::open_at(p)?;
709        assert_eq!(
710            repo.resolve_default_base_branch(),
711            Some("master".to_string())
712        );
713        Ok(())
714    }
715
716    #[test]
717    fn resolve_default_base_none_without_mainline() -> Result<()> {
718        let temp_dir = init_tmp_repo();
719        let p = temp_dir.path();
720        std::fs::write(p.join("f.txt"), "x")?;
721        git_in(p, &["checkout", "-b", "dev"]);
722        git_in(p, &["add", "."]);
723        git_in(p, &["commit", "-m", "init"]);
724        let repo = GitRepository::open_at(p)?;
725        assert_eq!(repo.resolve_default_base_branch(), None);
726        Ok(())
727    }
728
729    // ── in_main_branches population (issue #1105) ──────────────────
730
731    #[test]
732    fn commits_in_range_report_main_branch_containment() -> Result<()> {
733        // Work repo on `main` with one pushed commit and one unpushed commit
734        // on top of it.
735        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
736        std::fs::create_dir_all(&tmp_root)?;
737        let bare = tempfile::tempdir_in(&tmp_root)?;
738        git_in(bare.path(), &["init", "--bare"]);
739
740        let work = init_tmp_repo();
741        let p = work.path();
742        git_in(p, &["checkout", "-b", "main"]);
743        std::fs::write(p.join("a.txt"), "pushed")?;
744        git_in(p, &["add", "."]);
745        git_in(p, &["commit", "-m", "pushed commit"]);
746        #[allow(clippy::unwrap_used)]
747        git_in(
748            p,
749            &["remote", "add", "origin", bare.path().to_str().unwrap()],
750        );
751        git_in(p, &["push", "origin", "main"]);
752        std::fs::write(p.join("b.txt"), "unpushed")?;
753        git_in(p, &["add", "."]);
754        git_in(p, &["commit", "-m", "unpushed commit"]);
755
756        let repo = GitRepository::open_at(p)?;
757        // Single-rev path: the pushed commit is contained in origin/main.
758        let pushed = repo.get_commits_in_range("HEAD~1")?;
759        assert_eq!(pushed.len(), 1);
760        assert_eq!(pushed[0].in_main_branches, vec!["origin/main".to_string()]);
761        // Range path: the unpushed commit on top is not contained.
762        let unpushed = repo.get_commits_in_range("HEAD~1..HEAD")?;
763        assert_eq!(unpushed.len(), 1);
764        assert!(unpushed[0].in_main_branches.is_empty());
765        Ok(())
766    }
767
768    #[test]
769    fn commits_in_range_empty_containment_without_remotes() -> Result<()> {
770        let work = init_tmp_repo();
771        let p = work.path();
772        std::fs::write(p.join("a.txt"), "x")?;
773        git_in(p, &["add", "."]);
774        git_in(p, &["commit", "-m", "local only"]);
775
776        let repo = GitRepository::open_at(p)?;
777        let commits = repo.get_commits_in_range("HEAD")?;
778        assert_eq!(commits.len(), 1);
779        assert!(commits[0].in_main_branches.is_empty());
780        Ok(())
781    }
782
783    // ── get_commits_from_head (#1476) ───────────────────────────────
784
785    /// Creates a linear chain of `n` commits (subjects "commit 0".."commit
786    /// n-1", oldest first) in a fresh temp repo.
787    #[allow(clippy::unwrap_used)]
788    fn repo_with_linear_commits(n: usize) -> (tempfile::TempDir, std::path::PathBuf) {
789        let temp_dir = init_tmp_repo();
790        let p = temp_dir.path().to_path_buf();
791        for i in 0..n {
792            std::fs::write(p.join("f.txt"), format!("content {i}")).unwrap();
793            git_in(&p, &["add", "."]);
794            git_in(&p, &["commit", "-m", &format!("commit {i}")]);
795        }
796        (temp_dir, p)
797    }
798
799    #[test]
800    fn commits_from_head_no_cap_returns_all_in_chronological_order() -> Result<()> {
801        let (_tmp, p) = repo_with_linear_commits(3);
802        let repo = GitRepository::open_at(&p)?;
803        let commits = repo.get_commits_from_head(None)?;
804        let subjects: Vec<&str> = commits.iter().map(|c| c.original_message.trim()).collect();
805        assert_eq!(subjects, vec!["commit 0", "commit 1", "commit 2"]);
806        Ok(())
807    }
808
809    #[test]
810    fn commits_from_head_max_count_caps_to_newest() -> Result<()> {
811        let (_tmp, p) = repo_with_linear_commits(5);
812        let repo = GitRepository::open_at(&p)?;
813        let commits = repo.get_commits_from_head(Some(2))?;
814        let subjects: Vec<&str> = commits.iter().map(|c| c.original_message.trim()).collect();
815        // The newest 2 commits, still returned oldest-first.
816        assert_eq!(subjects, vec!["commit 3", "commit 4"]);
817        Ok(())
818    }
819
820    #[test]
821    fn commits_from_head_unborn_head_errors() -> Result<()> {
822        // A freshly `git init`ed repo with zero commits has an unborn HEAD,
823        // so `push_head()` errors — distinct from an empty *range* on an
824        // otherwise-populated repo (e.g. `HEAD..HEAD`), which the CLI layer
825        // handles as an empty report rather than a hard failure.
826        let temp_dir = init_tmp_repo();
827        let repo = GitRepository::open_at(temp_dir.path())?;
828        let result = repo.get_commits_from_head(None);
829        assert!(result.is_err(), "unborn HEAD is expected to error");
830        Ok(())
831    }
832
833    #[test]
834    fn commits_from_head_skips_merge_commits() -> Result<()> {
835        let (_tmp, p) = repo_with_linear_commits(1);
836        git_in(&p, &["checkout", "-b", "feature"]);
837        std::fs::write(p.join("g.txt"), "feature")?;
838        git_in(&p, &["add", "."]);
839        git_in(&p, &["commit", "-m", "feature commit"]);
840        // Back to the branch `feature` was cut from, then merge it in with a
841        // real merge commit (--no-ff, so a fast-forward can't collapse it away).
842        git_in(&p, &["checkout", "-"]);
843        git_in(&p, &["merge", "--no-ff", "feature", "-m", "merge feature"]);
844
845        let repo = GitRepository::open_at(&p)?;
846        let commits = repo.get_commits_from_head(None)?;
847        let subjects: Vec<&str> = commits.iter().map(|c| c.original_message.trim()).collect();
848        assert!(
849            !subjects.contains(&"merge feature"),
850            "merge commit must be excluded, got: {subjects:?}"
851        );
852        assert!(subjects.contains(&"commit 0"));
853        assert!(subjects.contains(&"feature commit"));
854        Ok(())
855    }
856
857    // ── tracked_files (issue #1475) ─────────────────────────────────
858
859    #[test]
860    fn tracked_files_includes_committed_and_staged() -> Result<()> {
861        let work = init_tmp_repo();
862        let p = work.path();
863        std::fs::write(p.join("a.txt"), "committed")?;
864        git_in(p, &["add", "."]);
865        git_in(p, &["commit", "-m", "init"]);
866        std::fs::write(p.join("b.txt"), "staged only")?;
867        git_in(p, &["add", "b.txt"]);
868
869        let repo = GitRepository::open_at(p)?;
870        let files = repo.tracked_files()?;
871        assert!(files.contains(&"a.txt".to_string()));
872        assert!(files.contains(&"b.txt".to_string()));
873        Ok(())
874    }
875
876    #[test]
877    fn tracked_files_excludes_untracked() -> Result<()> {
878        let work = init_tmp_repo();
879        let p = work.path();
880        std::fs::write(p.join("a.txt"), "committed")?;
881        git_in(p, &["add", "."]);
882        git_in(p, &["commit", "-m", "init"]);
883        std::fs::write(p.join("untracked.txt"), "never added")?;
884
885        let repo = GitRepository::open_at(p)?;
886        let files = repo.tracked_files()?;
887        assert!(!files.contains(&"untracked.txt".to_string()));
888        Ok(())
889    }
890
891    #[test]
892    fn tracked_files_sorted() -> Result<()> {
893        let work = init_tmp_repo();
894        let p = work.path();
895        std::fs::write(p.join("zeta.txt"), "z")?;
896        std::fs::write(p.join("alpha.txt"), "a")?;
897        git_in(p, &["add", "."]);
898        git_in(p, &["commit", "-m", "init"]);
899
900        let repo = GitRepository::open_at(p)?;
901        let files = repo.tracked_files()?;
902        let mut sorted = files.clone();
903        sorted.sort();
904        assert_eq!(files, sorted);
905        Ok(())
906    }
907
908    #[test]
909    fn tracked_files_empty_repo() -> Result<()> {
910        let temp_dir = init_tmp_repo();
911        let repo = GitRepository::open_at(temp_dir.path())?;
912        assert!(repo.tracked_files()?.is_empty());
913        Ok(())
914    }
915}