1use crate::error::{Error, Result};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub(crate) struct Upstream {
9 pub(crate) display: String,
11 pub(crate) tracking_ref: String,
13 pub(crate) is_gone: bool,
15}
16
17pub(crate) fn local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
19 let platform = repo
20 .references()
21 .map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
22 let iter = platform
23 .local_branches()
24 .map_err(|e| Error::operation(format!("cannot list branches: {e}")))?;
25 let mut names = Vec::new();
26 for reference in iter {
27 let reference =
28 reference.map_err(|e| Error::operation(format!("cannot read branch: {e}")))?;
29 names.push(reference.name().shorten().to_string());
30 }
31 names.sort();
32 Ok(names)
33}
34
35pub(crate) fn remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
39 let platform = repo
40 .references()
41 .map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
42 let iter = platform
43 .remote_branches()
44 .map_err(|e| Error::operation(format!("cannot list remote branches: {e}")))?;
45 let mut names = Vec::new();
46 for reference in iter {
47 let reference =
48 reference.map_err(|e| Error::operation(format!("cannot read remote branch: {e}")))?;
49 let name = reference.name().shorten().to_string();
50 if name.ends_with("/HEAD") {
52 continue;
53 }
54 names.push(name);
55 }
56 names.sort();
57 Ok(names)
58}
59
60pub(crate) fn all_branches(repo: &gix::Repository) -> Result<Vec<String>> {
64 let mut names = local_branches(repo)?;
65 names.extend(remote_branches(repo)?);
66 Ok(names)
67}
68
69pub(crate) fn branch_ref(branch: &str) -> String {
75 format!("refs/heads/{branch}")
76}
77
78pub(crate) fn validate_branch_name(name: &str) -> std::result::Result<(), String> {
85 use gix::bstr::ByteSlice;
86 let full = branch_ref(name);
87 gix::validate::reference::branch_name(full.as_bytes().as_bstr())
88 .map(|_| ())
89 .map_err(|e| format!("invalid branch name: {e}"))
90}
91
92pub(crate) fn resolve_hex(repo: &gix::Repository, spec: &str) -> Option<String> {
94 repo.rev_parse_single(spec)
95 .ok()
96 .map(|id| id.detach().to_string())
97}
98
99pub(crate) fn upstream_of(repo: &gix::Repository, branch: &str) -> Option<Upstream> {
101 let config = repo.config_snapshot();
102 let remote = config.string(format!("branch.{branch}.remote").as_str())?;
103 let merge = config.string(format!("branch.{branch}.merge").as_str())?;
104 let remote = remote.to_string();
105 let merge = merge.to_string();
106 let merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
107 let display = format!("{remote}/{merge_branch}");
108 let tracking_ref = format!("refs/remotes/{remote}/{merge_branch}");
109 let is_gone = resolve_hex(repo, &tracking_ref).is_none();
110 Some(Upstream {
111 display,
112 tracking_ref,
113 is_gone,
114 })
115}
116
117pub(crate) fn is_ancestor(repo: &gix::Repository, a: &str, b: &str) -> bool {
124 let Some(a_id) = repo.rev_parse_single(a).ok().map(|id| id.detach()) else {
125 return false;
126 };
127 let Some(b_id) = repo.rev_parse_single(b).ok().map(|id| id.detach()) else {
128 return false;
129 };
130 repo.merge_base(a_id, b_id)
131 .map(|base| base.detach() == a_id)
132 .unwrap_or(false)
133}
134
135pub(crate) fn default_branch(repo: &gix::Repository) -> Option<String> {
139 origin_head_branch(repo).or_else(|| current_branch(repo))
140}
141
142pub(crate) fn default_base_ref(repo: &gix::Repository) -> Option<String> {
148 origin_head_tracking(repo)
149}
150
151pub(crate) fn current_branch(repo: &gix::Repository) -> Option<String> {
153 let head = repo.head().ok()?;
154 head.referent_name().map(|name| name.shorten().to_string())
155}
156
157pub(crate) fn origin_head_branch(repo: &gix::Repository) -> Option<String> {
160 origin_head_tracking(repo)?
163 .split_once('/')
164 .map(|(_, branch)| branch.to_string())
165}
166
167fn origin_head_tracking(repo: &gix::Repository) -> Option<String> {
170 let reference = repo.find_reference("refs/remotes/origin/HEAD").ok()?;
171 match reference.target() {
172 gix::refs::TargetRef::Symbolic(name) => {
173 let full = name.as_bstr().to_string();
175 full.strip_prefix("refs/remotes/").map(str::to_string)
176 }
177 gix::refs::TargetRef::Object(_) => None,
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::git::discover::Repo;
185 use crate::testutil::TestRepo;
186
187 #[test]
188 fn lists_local_branches_sorted() {
189 let repo = TestRepo::init();
190 repo.git(&["branch", "zeta"]);
191 repo.git(&["branch", "alpha"]);
192 let r = Repo::discover(repo.root()).unwrap();
193 let branches = local_branches(r.gix()).unwrap();
194 assert_eq!(branches, vec!["alpha", "main", "zeta"]);
195 }
196
197 #[test]
198 fn lists_remote_branches_skipping_head() {
199 let repo = TestRepo::init();
200 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
201 repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
202 repo.git(&["update-ref", "refs/remotes/origin/feature/x", &head]);
203 repo.git(&[
205 "symbolic-ref",
206 "refs/remotes/origin/HEAD",
207 "refs/remotes/origin/main",
208 ]);
209 let r = Repo::discover(repo.root()).unwrap();
210 let remotes = remote_branches(r.gix()).unwrap();
211 assert_eq!(remotes, vec!["origin/feature/x", "origin/main"]);
212 }
213
214 #[test]
215 fn all_branches_lists_locals_then_remotes() {
216 let repo = TestRepo::init();
217 repo.git(&["branch", "zeta"]);
218 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
219 repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
220 let r = Repo::discover(repo.root()).unwrap();
221 let all = all_branches(r.gix()).unwrap();
222 assert_eq!(all, vec!["main", "zeta", "origin/main"]);
224 }
225
226 #[test]
227 fn validates_branch_names() {
228 for ok in ["feature", "feature/x", "fix-bug_123", "release/v1.2"] {
230 assert!(
231 validate_branch_name(ok).is_ok(),
232 "expected {ok:?} to be valid"
233 );
234 }
235 for bad in [
237 "feat..x", "a b", "*x", ".hidden", "feature/", "x.lock", "HEAD", "",
238 ] {
239 let err = validate_branch_name(bad).unwrap_err();
240 assert!(
241 err.starts_with("invalid branch name:"),
242 "expected {bad:?} to be rejected, got {err:?}"
243 );
244 }
245 }
246
247 #[test]
248 fn branch_ref_prefixes_refs_heads() {
249 assert_eq!(branch_ref("main"), "refs/heads/main");
250 assert_eq!(branch_ref("feature/login"), "refs/heads/feature/login");
252 }
253
254 #[test]
255 fn resolves_refs() {
256 let repo = TestRepo::init();
257 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
258 let r = Repo::discover(repo.root()).unwrap();
259 assert_eq!(resolve_hex(r.gix(), "HEAD").as_deref(), Some(head.as_str()));
260 assert_eq!(
261 resolve_hex(r.gix(), "refs/heads/main").as_deref(),
262 Some(head.as_str())
263 );
264 assert!(resolve_hex(r.gix(), "refs/heads/nope").is_none());
265 }
266
267 #[test]
268 fn upstream_present_absent_and_gone() {
269 let repo = TestRepo::init();
270 let r = Repo::discover(repo.root()).unwrap();
271 assert!(upstream_of(r.gix(), "main").is_none());
273
274 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
276 repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
277 repo.git(&["config", "branch.main.remote", "origin"]);
278 repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
279 let r = Repo::discover(repo.root()).unwrap();
280 let up = upstream_of(r.gix(), "main").unwrap();
281 assert_eq!(up.display, "origin/main");
282 assert_eq!(up.tracking_ref, "refs/remotes/origin/main");
283 assert!(!up.is_gone);
284
285 repo.git(&["update-ref", "-d", "refs/remotes/origin/main"]);
287 let r = Repo::discover(repo.root()).unwrap();
288 let up = upstream_of(r.gix(), "main").unwrap();
289 assert!(up.is_gone);
290 }
291
292 #[test]
293 fn default_branch_prefers_origin_head() {
294 let repo = TestRepo::init();
295 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
296 repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
297 repo.git(&[
298 "symbolic-ref",
299 "refs/remotes/origin/HEAD",
300 "refs/remotes/origin/main",
301 ]);
302 let r = Repo::discover(repo.root()).unwrap();
303 assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
304 }
305
306 #[test]
307 fn default_branch_falls_back_to_current() {
308 let repo = TestRepo::init();
309 let r = Repo::discover(repo.root()).unwrap();
310 assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
311 assert_eq!(current_branch(r.gix()).as_deref(), Some("main"));
312 }
313
314 #[test]
315 fn default_base_ref_is_origin_head_tracking_form() {
316 let repo = TestRepo::init();
319 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
320 repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
321 repo.git(&[
322 "symbolic-ref",
323 "refs/remotes/origin/HEAD",
324 "refs/remotes/origin/main",
325 ]);
326 let r = Repo::discover(repo.root()).unwrap();
327 assert_eq!(default_base_ref(r.gix()).as_deref(), Some("origin/main"));
328 }
329
330 #[test]
331 fn default_base_ref_none_without_origin_head() {
332 let repo = TestRepo::init();
335 let r = Repo::discover(repo.root()).unwrap();
336 assert_eq!(default_base_ref(r.gix()), None);
337 }
338
339 #[test]
340 fn is_ancestor_true_when_merged_false_when_divergent() {
341 let repo = TestRepo::init();
342 repo.git(&["branch", "topic"]);
344 let r = Repo::discover(repo.root()).unwrap();
345 assert!(is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
346 assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/main"));
348 repo.git(&["checkout", "topic"]);
350 repo.write("t.txt", "1\n");
351 repo.commit_all("topic work");
352 let r = Repo::discover(repo.root()).unwrap();
353 assert!(!is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
354 assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/topic"));
356 }
357
358 #[test]
359 fn is_ancestor_false_for_missing_ref() {
360 let repo = TestRepo::init();
361 let r = Repo::discover(repo.root()).unwrap();
362 assert!(!is_ancestor(r.gix(), "refs/heads/nope", "refs/heads/main"));
363 }
364
365 #[test]
366 fn is_ancestor_false_for_unrelated_histories() {
367 let repo = TestRepo::init();
370 repo.git(&["checkout", "--orphan", "unrelated"]);
371 repo.write("o.txt", "x\n");
372 repo.commit_all("orphan root");
373 let r = Repo::discover(repo.root()).unwrap();
374 assert!(!is_ancestor(
375 r.gix(),
376 "refs/heads/main",
377 "refs/heads/unrelated"
378 ));
379 }
380}