use std::path::Path;
use crate::cell::{Settled, Timestamp, Unknown};
use crate::entity::{DefaultBranch, DefaultBranchStopped};
const NAME_LIST: [&str; 3] = ["main", "master", "trunk"];
#[derive(Clone)]
pub(crate) struct Resolution {
pub settled: Settled<DefaultBranch>,
pub rung: u8,
pub disagreement: bool,
pub stale_remote_head: bool,
pub stopped: Option<DefaultBranchStopped>,
}
impl Resolution {
pub(crate) fn failed(error: crate::git::ProbeError) -> Self {
Resolution {
settled: Settled::Failed(error),
rung: 0,
disagreement: false,
stale_remote_head: false,
stopped: None,
}
}
pub(crate) fn submodule_uninitialized() -> Self {
Resolution {
settled: Settled::Unknown(crate::cell::Unknown::SubmoduleUninitialized),
rung: 0,
disagreement: false,
stale_remote_head: false,
stopped: None,
}
}
fn known(rung: u8, name: String, disagreement: bool, stale_remote_head: bool) -> Self {
Resolution {
settled: Settled::Known {
value: DefaultBranch::new(name.as_str().into()),
at: Timestamp::now(),
stale: false,
},
rung,
disagreement,
stale_remote_head,
stopped: None,
}
}
fn unknown(stopped: DefaultBranchStopped, stale_remote_head: bool) -> Self {
Resolution {
settled: Settled::Unknown(Unknown::NoDefaultBranch),
rung: 4,
disagreement: false,
stale_remote_head,
stopped: Some(stopped),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ChainFacts {
remote: Option<String>,
has_any_remote: bool,
rung2_name: Option<String>,
stale_remote_head: bool,
rung3_name: Option<String>,
disagreement: bool,
}
impl ChainFacts {
pub(crate) fn resolve(repo: &gix::Repository) -> Self {
let has_any_remote = crate::git::has_any_remote(repo);
let Some(remote) = chosen_remote(repo) else {
return ChainFacts {
remote: None,
has_any_remote,
rung2_name: None,
stale_remote_head: false,
rung3_name: None,
disagreement: false,
};
};
let rung2 = remote_head(repo, &remote);
let rung3_name = name_list(repo, &remote);
let (rung2_name, stale_remote_head) = match rung2 {
RemoteHead::Resolved(name) => (Some(name), false),
RemoteHead::Stale => (None, true),
RemoteHead::Absent => (None, false),
};
let disagreement = matches!((&rung2_name, &rung3_name), (Some(a), Some(b)) if a != b);
ChainFacts {
remote: Some(remote),
has_any_remote,
rung2_name,
stale_remote_head,
rung3_name,
disagreement,
}
}
}
pub(crate) fn resolve_with_facts(facts: &ChainFacts, override_branch: Option<&str>) -> Resolution {
if let Some(name) = override_branch {
let name = match &facts.remote {
Some(remote) => format!("{remote}/{name}"),
None => name.to_string(),
};
return Resolution::known(1, name, false, false);
}
if facts.remote.is_none() {
let stopped = if facts.has_any_remote {
DefaultBranchStopped::AmbiguousRemote
} else {
DefaultBranchStopped::NoRemote
};
return Resolution::unknown(stopped, false);
}
if let Some(name) = &facts.rung2_name {
return Resolution::known(2, name.clone(), facts.disagreement, facts.stale_remote_head);
}
if let Some(name) = &facts.rung3_name {
return Resolution::known(3, name.clone(), false, facts.stale_remote_head);
}
Resolution::unknown(
DefaultBranchStopped::NameListExhausted,
facts.stale_remote_head,
)
}
pub(crate) fn resolve(repo: &gix::Repository, override_branch: Option<&str>) -> Resolution {
resolve_with_facts(&ChainFacts::resolve(repo), override_branch)
}
fn chosen_remote(repo: &gix::Repository) -> Option<String> {
repo.remote_default_name(gix::remote::Direction::Fetch)
.map(|name| name.to_string())
}
enum RemoteHead {
Resolved(String),
Stale,
Absent,
}
fn remote_head(repo: &gix::Repository, remote: &str) -> RemoteHead {
let Some(target) = symbolic_target_from_loose_file(repo.common_dir(), remote)
.or_else(|| symbolic_target_from_normal_lookup(repo, remote))
else {
return RemoteHead::Absent;
};
match repo.try_find_reference(target.as_str()).ok().flatten() {
Some(_) => RemoteHead::Resolved(strip_remotes_prefix(&target).to_string()),
None => RemoteHead::Stale,
}
}
fn symbolic_target_from_loose_file(common_dir: &Path, remote: &str) -> Option<String> {
let path = common_dir
.join("refs")
.join("remotes")
.join(remote)
.join("HEAD");
let contents = std::fs::read_to_string(path).ok()?;
contents
.strip_prefix("ref: ")
.map(|target| target.trim().to_string())
}
fn symbolic_target_from_normal_lookup(repo: &gix::Repository, remote: &str) -> Option<String> {
let name = format!("refs/remotes/{remote}/HEAD");
let reference = repo.try_find_reference(name.as_str()).ok().flatten()?;
match reference.target() {
gix::refs::TargetRef::Symbolic(full_name) => Some(full_name.as_bstr().to_string()),
gix::refs::TargetRef::Object(_) => None,
}
}
fn strip_remotes_prefix(name: &str) -> &str {
name.strip_prefix("refs/remotes/").unwrap_or(name)
}
fn name_list(repo: &gix::Repository, remote: &str) -> Option<String> {
NAME_LIST.iter().find_map(|name| {
let full = format!("refs/remotes/{remote}/{name}");
repo.try_find_reference(full.as_str())
.ok()
.flatten()
.map(|_| format!("{remote}/{name}"))
})
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use super::*;
use crate::test_support::{git, head_sha};
fn init_repo_with_a_commit(path: &Path) {
fs::create_dir_all(path).expect("create repo dir");
git(path, &["init", "-q"]);
git(path, &["commit", "--allow-empty", "-m", "first"]);
}
fn add_remote(path: &Path, name: &str) {
git(
path,
&["remote", "add", name, "https://example.invalid/repo.git"],
);
}
fn set_remote_tracking_ref(path: &Path, remote: &str, branch: &str, sha: &str) {
git(
path,
&[
"update-ref",
&format!("refs/remotes/{remote}/{branch}"),
sha,
],
);
}
fn write_symbolic_remote_head(path: &Path, remote: &str, target: &str) {
let dir = path.join(".git").join("refs").join("remotes").join(remote);
fs::create_dir_all(&dir).expect("create refs/remotes/<remote> dir");
fs::write(dir.join("HEAD"), format!("ref: {target}\n")).expect("write HEAD");
}
fn open(path: &Path) -> gix::Repository {
gix::open(path).expect("open repo")
}
#[test]
fn no_remote_and_a_local_branch_resolve_as_unknown_at_rung_four() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
git(&repo, &["branch", "-M", "main"]);
let resolution = resolve(&open(&repo), None);
assert!(matches!(
resolution.settled,
Settled::Unknown(Unknown::NoDefaultBranch)
));
assert_eq!(resolution.rung, 4);
}
#[test]
fn a_valid_symbolic_remote_head_answers_at_rung_two() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "origin", "trunk", &sha);
write_symbolic_remote_head(&repo, "origin", "refs/remotes/origin/trunk");
let resolution = resolve(&open(&repo), None);
match resolution.settled {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(value.name(), "origin/trunk"),
other => panic!("expected a known default branch, got {other:?}"),
}
assert_eq!(
resolution.rung, 2,
"a valid origin/HEAD must answer at rung 2"
);
assert!(
!resolution.stale_remote_head,
"a resolvable origin/HEAD must never be reported as stale"
);
}
#[test]
fn a_stale_symbolic_remote_head_falls_through_to_the_name_list() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "origin", "trunk", &sha);
write_symbolic_remote_head(&repo, "origin", "refs/remotes/origin/main");
let resolution = resolve(&open(&repo), None);
match resolution.settled {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(
value.name(),
"origin/trunk",
"a stale rung 2 must fall through to rung 3's real answer, not the dead name"
),
other => panic!("expected the name list's answer, got {other:?}"),
}
assert_eq!(
resolution.rung, 3,
"the stale rung must never be reported as having answered"
);
assert!(
resolution.stale_remote_head,
"the stale target must be recorded even though rung 3 answered instead"
);
}
#[test]
fn no_remote_head_at_all_answers_from_the_name_list() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "origin", "master", &sha);
let resolution = resolve(&open(&repo), None);
match resolution.settled {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(value.name(), "origin/master"),
other => panic!("expected the name list's answer, got {other:?}"),
}
assert_eq!(resolution.rung, 3);
}
#[test]
fn two_remotes_with_neither_named_origin_never_guess() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "fork-one");
add_remote(&repo, "fork-two");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "fork-one", "main", &sha);
let resolution = resolve(&open(&repo), None);
assert!(matches!(
resolution.settled,
Settled::Unknown(Unknown::NoDefaultBranch)
));
assert_eq!(resolution.rung, 4);
}
#[test]
fn an_override_wins_over_a_disagreeing_remote_head() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "origin", "main", &sha);
write_symbolic_remote_head(&repo, "origin", "refs/remotes/origin/main");
let resolution = resolve(&open(&repo), Some("develop"));
match resolution.settled {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(value.name(), "origin/develop"),
other => panic!("expected the override's own answer, got {other:?}"),
}
assert_eq!(
resolution.rung, 1,
"an override must win outright, first answer wins"
);
}
#[test]
fn rung_two_and_rung_three_agreeing_is_not_a_disagreement() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "origin", "main", &sha);
write_symbolic_remote_head(&repo, "origin", "refs/remotes/origin/main");
let resolution = resolve(&open(&repo), None);
assert_eq!(resolution.rung, 2);
assert!(!resolution.disagreement);
}
#[test]
fn rung_two_and_rung_three_disagreeing_is_recorded_while_rung_two_still_wins() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let sha = head_sha(&repo);
set_remote_tracking_ref(&repo, "origin", "main", &sha);
set_remote_tracking_ref(&repo, "origin", "develop", &sha);
write_symbolic_remote_head(&repo, "origin", "refs/remotes/origin/develop");
let resolution = resolve(&open(&repo), None);
match resolution.settled {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(value.name(), "origin/develop"),
other => panic!("expected rung 2's own answer to still win, got {other:?}"),
}
assert_eq!(resolution.rung, 2);
assert!(
resolution.disagreement,
"rung 2 and rung 3 picked different names and that must be recorded"
);
}
}