Skip to main content

omni_dev/git/
main_branches.rs

1//! Detection of remote main-branch tips and commit containment.
2
3use anyhow::{Context, Result};
4use git2::{Oid, Repository};
5
6use crate::git::remote::RemoteInfo;
7
8/// A resolved remote main branch: display name plus the commit it points at.
9#[derive(Debug, Clone)]
10pub struct MainBranchTip {
11    /// Display name, e.g. `origin/main`.
12    pub name: String,
13    /// Commit the local remote-tracking ref currently points at.
14    pub tip: Oid,
15}
16
17/// Resolves the main-branch tip for every remote, using only local refs.
18///
19/// Works offline: detection never shells out to `gh` or touches the network.
20/// Remotes whose main branch cannot be determined locally are skipped, so an
21/// unresolvable remote contributes no tip rather than a wrong one.
22pub fn detect_main_branch_tips(repo: &Repository) -> Result<Vec<MainBranchTip>> {
23    let mut tips = Vec::new();
24    let remote_names = repo.remotes().context("Failed to get remote names")?;
25
26    for remote_name in remote_names.iter().flatten().flatten() {
27        let Some(branch_name) = RemoteInfo::detect_main_branch_local(repo, remote_name) else {
28            continue;
29        };
30
31        let reference_name = format!("refs/remotes/{remote_name}/{branch_name}");
32        let Ok(reference) = repo.find_reference(&reference_name) else {
33            continue;
34        };
35        let Ok(commit) = reference.peel_to_commit() else {
36            continue;
37        };
38
39        tips.push(MainBranchTip {
40            name: format!("{remote_name}/{branch_name}"),
41            tip: commit.id(),
42        });
43    }
44
45    Ok(tips)
46}
47
48/// Returns the display names of the given main branches that contain
49/// `commit_oid` (the commit is the branch tip or an ancestor of it).
50pub fn branches_containing(
51    repo: &Repository,
52    tips: &[MainBranchTip],
53    commit_oid: Oid,
54) -> Result<Vec<String>> {
55    let mut containing = Vec::new();
56
57    for tip in tips {
58        let contained = commit_oid == tip.tip
59            || repo
60                .graph_descendant_of(tip.tip, commit_oid)
61                .with_context(|| {
62                    format!("Failed to check whether {} contains {commit_oid}", tip.name)
63                })?;
64        if contained {
65            containing.push(tip.name.clone());
66        }
67    }
68
69    Ok(containing)
70}
71
72#[cfg(test)]
73#[allow(clippy::unwrap_used, clippy::expect_used)]
74mod tests {
75    use super::*;
76
77    /// Runs `git` in `dir` with a deterministic identity, asserting success.
78    fn git_in(dir: &std::path::Path, args: &[&str]) {
79        let output = std::process::Command::new("git")
80            .current_dir(dir)
81            .args([
82                "-c",
83                "user.email=test@example.com",
84                "-c",
85                "user.name=Test",
86                "-c",
87                "commit.gpgsign=false",
88                "-c",
89                "tag.gpgsign=false",
90            ])
91            .args(args)
92            .output()
93            .unwrap();
94        let stderr = String::from_utf8_lossy(&output.stderr);
95        assert!(output.status.success(), "git {args:?} failed: {stderr}");
96    }
97
98    /// Builds a work repo on `branch` with one commit pushed to a bare
99    /// `origin` remote. Both temp dirs are returned so the caller keeps them
100    /// alive for the duration of the test.
101    fn repo_with_pushed_branch(branch: &str) -> (tempfile::TempDir, tempfile::TempDir, Repository) {
102        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
103        std::fs::create_dir_all(&tmp_root).unwrap();
104        let bare = tempfile::tempdir_in(&tmp_root).unwrap();
105        git_in(bare.path(), &["init", "--bare"]);
106
107        let work = tempfile::tempdir_in(&tmp_root).unwrap();
108        git_in(work.path(), &["init"]);
109        git_in(work.path(), &["checkout", "-b", branch]);
110        std::fs::write(work.path().join("file.txt"), "content").unwrap();
111        git_in(work.path(), &["add", "."]);
112        git_in(work.path(), &["commit", "-m", "initial"]);
113        git_in(
114            work.path(),
115            &["remote", "add", "origin", bare.path().to_str().unwrap()],
116        );
117        git_in(work.path(), &["push", "origin", branch]);
118
119        let repo = Repository::open(work.path()).unwrap();
120        (work, bare, repo)
121    }
122
123    fn add_commit(dir: &std::path::Path, file: &str, message: &str) {
124        std::fs::write(dir.join(file), message).unwrap();
125        git_in(dir, &["add", "."]);
126        git_in(dir, &["commit", "-m", message]);
127    }
128
129    #[test]
130    fn tips_resolve_via_common_name_after_push() {
131        let (_work, _bare, repo) = repo_with_pushed_branch("main");
132        let tips = detect_main_branch_tips(&repo).unwrap();
133        assert_eq!(tips.len(), 1);
134        assert_eq!(tips[0].name, "origin/main");
135        let pushed = repo.refname_to_id("refs/remotes/origin/main").unwrap();
136        assert_eq!(tips[0].tip, pushed);
137    }
138
139    #[test]
140    fn tips_resolve_via_symbolic_head_for_uncommon_branch_name() {
141        // `trunk` is not in the common-names fallback, so detection must come
142        // from the remote's symbolic HEAD alone.
143        let (work, _bare, repo) = repo_with_pushed_branch("trunk");
144        git_in(work.path(), &["remote", "set-head", "origin", "trunk"]);
145        let tips = detect_main_branch_tips(&repo).unwrap();
146        assert_eq!(tips.len(), 1);
147        assert_eq!(tips[0].name, "origin/trunk");
148    }
149
150    #[test]
151    fn unresolvable_remote_contributes_no_tip() {
152        // No symbolic HEAD and no common branch name: the remote is skipped
153        // rather than guessing that `exotic` is a main branch.
154        let (_work, _bare, repo) = repo_with_pushed_branch("exotic");
155        let tips = detect_main_branch_tips(&repo).unwrap();
156        assert!(tips.is_empty(), "expected no tips, got: {tips:?}");
157    }
158
159    #[test]
160    fn repo_without_remotes_has_no_tips() {
161        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
162        std::fs::create_dir_all(&tmp_root).unwrap();
163        let work = tempfile::tempdir_in(&tmp_root).unwrap();
164        let repo = Repository::init(work.path()).unwrap();
165        let tips = detect_main_branch_tips(&repo).unwrap();
166        assert!(tips.is_empty());
167    }
168
169    #[test]
170    fn branches_containing_distinguishes_pushed_from_unpushed() {
171        let (work, _bare, repo) = repo_with_pushed_branch("main");
172        let pushed = repo.refname_to_id("refs/remotes/origin/main").unwrap();
173        add_commit(work.path(), "new.txt", "unpushed change");
174        let unpushed = repo.head().unwrap().target().unwrap();
175
176        let tips = detect_main_branch_tips(&repo).unwrap();
177        // The pushed commit is the tip itself (equality case).
178        assert_eq!(
179            branches_containing(&repo, &tips, pushed).unwrap(),
180            vec!["origin/main".to_string()]
181        );
182        assert!(branches_containing(&repo, &tips, unpushed)
183            .unwrap()
184            .is_empty());
185    }
186
187    #[test]
188    fn branches_containing_includes_ancestors_of_tip() {
189        let (work, _bare, repo) = repo_with_pushed_branch("main");
190        let first = repo.refname_to_id("refs/remotes/origin/main").unwrap();
191        add_commit(work.path(), "second.txt", "second");
192        git_in(work.path(), &["push", "origin", "main"]);
193
194        let tips = detect_main_branch_tips(&repo).unwrap();
195        assert_eq!(
196            branches_containing(&repo, &tips, first).unwrap(),
197            vec!["origin/main".to_string()]
198        );
199    }
200}