use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Upstream {
pub(crate) display: String,
pub(crate) tracking_ref: String,
pub(crate) is_gone: bool,
}
pub(crate) fn local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
let platform = repo
.references()
.map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
let iter = platform
.local_branches()
.map_err(|e| Error::operation(format!("cannot list branches: {e}")))?;
let mut names = Vec::new();
for reference in iter {
let reference =
reference.map_err(|e| Error::operation(format!("cannot read branch: {e}")))?;
names.push(reference.name().shorten().to_string());
}
names.sort();
Ok(names)
}
pub(crate) fn remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
let platform = repo
.references()
.map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
let iter = platform
.remote_branches()
.map_err(|e| Error::operation(format!("cannot list remote branches: {e}")))?;
let mut names = Vec::new();
for reference in iter {
let reference =
reference.map_err(|e| Error::operation(format!("cannot read remote branch: {e}")))?;
let name = reference.name().shorten().to_string();
if name.ends_with("/HEAD") {
continue;
}
names.push(name);
}
names.sort();
Ok(names)
}
pub(crate) fn all_branches(repo: &gix::Repository) -> Result<Vec<String>> {
let mut names = local_branches(repo)?;
names.extend(remote_branches(repo)?);
Ok(names)
}
pub(crate) fn branch_ref(branch: &str) -> String {
format!("refs/heads/{branch}")
}
pub(crate) fn validate_branch_name(name: &str) -> std::result::Result<(), String> {
use gix::bstr::ByteSlice;
let full = branch_ref(name);
gix::validate::reference::branch_name(full.as_bytes().as_bstr())
.map(|_| ())
.map_err(|e| format!("invalid branch name: {e}"))
}
pub(crate) fn resolve_hex(repo: &gix::Repository, spec: &str) -> Option<String> {
repo.rev_parse_single(spec)
.ok()
.map(|id| id.detach().to_string())
}
pub(crate) fn upstream_of(repo: &gix::Repository, branch: &str) -> Option<Upstream> {
let config = repo.config_snapshot();
let remote = config.string(format!("branch.{branch}.remote").as_str())?;
let merge = config.string(format!("branch.{branch}.merge").as_str())?;
let remote = remote.to_string();
let merge = merge.to_string();
let merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
let display = format!("{remote}/{merge_branch}");
let tracking_ref = format!("refs/remotes/{remote}/{merge_branch}");
let is_gone = resolve_hex(repo, &tracking_ref).is_none();
Some(Upstream {
display,
tracking_ref,
is_gone,
})
}
pub(crate) fn is_ancestor(repo: &gix::Repository, a: &str, b: &str) -> bool {
let Some(a_id) = repo.rev_parse_single(a).ok().map(|id| id.detach()) else {
return false;
};
let Some(b_id) = repo.rev_parse_single(b).ok().map(|id| id.detach()) else {
return false;
};
repo.merge_base(a_id, b_id)
.map(|base| base.detach() == a_id)
.unwrap_or(false)
}
pub(crate) fn default_branch(repo: &gix::Repository) -> Option<String> {
origin_head_branch(repo).or_else(|| current_branch(repo))
}
pub(crate) fn default_base_ref(repo: &gix::Repository) -> Option<String> {
origin_head_tracking(repo)
}
pub(crate) fn current_branch(repo: &gix::Repository) -> Option<String> {
let head = repo.head().ok()?;
head.referent_name().map(|name| name.shorten().to_string())
}
pub(crate) fn origin_head_branch(repo: &gix::Repository) -> Option<String> {
origin_head_tracking(repo)?
.split_once('/')
.map(|(_, branch)| branch.to_string())
}
fn origin_head_tracking(repo: &gix::Repository) -> Option<String> {
let reference = repo.find_reference("refs/remotes/origin/HEAD").ok()?;
match reference.target() {
gix::refs::TargetRef::Symbolic(name) => {
let full = name.as_bstr().to_string();
full.strip_prefix("refs/remotes/").map(str::to_string)
}
gix::refs::TargetRef::Object(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::discover::Repo;
use crate::testutil::TestRepo;
#[test]
fn lists_local_branches_sorted() {
let repo = TestRepo::init();
repo.git(&["branch", "zeta"]);
repo.git(&["branch", "alpha"]);
let r = Repo::discover(repo.root()).unwrap();
let branches = local_branches(r.gix()).unwrap();
assert_eq!(branches, vec!["alpha", "main", "zeta"]);
}
#[test]
fn lists_remote_branches_skipping_head() {
let repo = TestRepo::init();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
repo.git(&["update-ref", "refs/remotes/origin/feature/x", &head]);
repo.git(&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
]);
let r = Repo::discover(repo.root()).unwrap();
let remotes = remote_branches(r.gix()).unwrap();
assert_eq!(remotes, vec!["origin/feature/x", "origin/main"]);
}
#[test]
fn all_branches_lists_locals_then_remotes() {
let repo = TestRepo::init();
repo.git(&["branch", "zeta"]);
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
let r = Repo::discover(repo.root()).unwrap();
let all = all_branches(r.gix()).unwrap();
assert_eq!(all, vec!["main", "zeta", "origin/main"]);
}
#[test]
fn validates_branch_names() {
for ok in ["feature", "feature/x", "fix-bug_123", "release/v1.2"] {
assert!(
validate_branch_name(ok).is_ok(),
"expected {ok:?} to be valid"
);
}
for bad in [
"feat..x", "a b", "*x", ".hidden", "feature/", "x.lock", "HEAD", "",
] {
let err = validate_branch_name(bad).unwrap_err();
assert!(
err.starts_with("invalid branch name:"),
"expected {bad:?} to be rejected, got {err:?}"
);
}
}
#[test]
fn branch_ref_prefixes_refs_heads() {
assert_eq!(branch_ref("main"), "refs/heads/main");
assert_eq!(branch_ref("feature/login"), "refs/heads/feature/login");
}
#[test]
fn resolves_refs() {
let repo = TestRepo::init();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
let r = Repo::discover(repo.root()).unwrap();
assert_eq!(resolve_hex(r.gix(), "HEAD").as_deref(), Some(head.as_str()));
assert_eq!(
resolve_hex(r.gix(), "refs/heads/main").as_deref(),
Some(head.as_str())
);
assert!(resolve_hex(r.gix(), "refs/heads/nope").is_none());
}
#[test]
fn upstream_present_absent_and_gone() {
let repo = TestRepo::init();
let r = Repo::discover(repo.root()).unwrap();
assert!(upstream_of(r.gix(), "main").is_none());
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
repo.git(&["config", "branch.main.remote", "origin"]);
repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
let r = Repo::discover(repo.root()).unwrap();
let up = upstream_of(r.gix(), "main").unwrap();
assert_eq!(up.display, "origin/main");
assert_eq!(up.tracking_ref, "refs/remotes/origin/main");
assert!(!up.is_gone);
repo.git(&["update-ref", "-d", "refs/remotes/origin/main"]);
let r = Repo::discover(repo.root()).unwrap();
let up = upstream_of(r.gix(), "main").unwrap();
assert!(up.is_gone);
}
#[test]
fn default_branch_prefers_origin_head() {
let repo = TestRepo::init();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
repo.git(&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
]);
let r = Repo::discover(repo.root()).unwrap();
assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
}
#[test]
fn default_branch_falls_back_to_current() {
let repo = TestRepo::init();
let r = Repo::discover(repo.root()).unwrap();
assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
assert_eq!(current_branch(r.gix()).as_deref(), Some("main"));
}
#[test]
fn default_base_ref_is_origin_head_tracking_form() {
let repo = TestRepo::init();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
repo.git(&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
]);
let r = Repo::discover(repo.root()).unwrap();
assert_eq!(default_base_ref(r.gix()).as_deref(), Some("origin/main"));
}
#[test]
fn default_base_ref_none_without_origin_head() {
let repo = TestRepo::init();
let r = Repo::discover(repo.root()).unwrap();
assert_eq!(default_base_ref(r.gix()), None);
}
#[test]
fn is_ancestor_true_when_merged_false_when_divergent() {
let repo = TestRepo::init();
repo.git(&["branch", "topic"]);
let r = Repo::discover(repo.root()).unwrap();
assert!(is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/main"));
repo.git(&["checkout", "topic"]);
repo.write("t.txt", "1\n");
repo.commit_all("topic work");
let r = Repo::discover(repo.root()).unwrap();
assert!(!is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/topic"));
}
#[test]
fn is_ancestor_false_for_missing_ref() {
let repo = TestRepo::init();
let r = Repo::discover(repo.root()).unwrap();
assert!(!is_ancestor(r.gix(), "refs/heads/nope", "refs/heads/main"));
}
#[test]
fn is_ancestor_false_for_unrelated_histories() {
let repo = TestRepo::init();
repo.git(&["checkout", "--orphan", "unrelated"]);
repo.write("o.txt", "x\n");
repo.commit_all("orphan root");
let r = Repo::discover(repo.root()).unwrap();
assert!(!is_ancestor(
r.gix(),
"refs/heads/main",
"refs/heads/unrelated"
));
}
}