1use anyhow::{Context, Result};
4use git2::{Oid, Repository};
5
6use crate::git::remote::RemoteInfo;
7
8#[derive(Debug, Clone)]
10pub struct MainBranchTip {
11 pub name: String,
13 pub tip: Oid,
15}
16
17pub 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
48pub 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 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 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 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 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 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}