use crate::cell::{Settled, Timestamp};
use crate::entity::{DefaultBranch, Head};
use crate::git::{self, ProbeError};
use crate::landing;
pub(crate) fn probe(
repo: &gix::Repository,
head: &Head,
default_branch: &Settled<DefaultBranch>,
) -> Settled<u32> {
if !git::has_any_remote(repo) {
return Settled::NotApplicable;
}
let default_branch = match default_branch {
Settled::Known {
value,
at: _,
stale: _,
} => value,
Settled::Unknown(reason) => return Settled::Unknown(*reason),
Settled::Failed(error) => return Settled::Failed(error.clone()),
Settled::NotApplicable => return Settled::NotApplicable,
};
let commit = match head {
Head::Branch { name, commit } => {
if branch_is_default_branchs_own_row(repo, name, default_branch) {
return Settled::NotApplicable;
}
*commit
}
Head::Detached(commit) => *commit,
Head::Unborn(_) => return Settled::NotApplicable,
};
let default_commit = match landing::resolve_ref_commit(repo, default_branch.name()) {
Ok(id) => id,
Err(error) => return Settled::Failed(error),
};
match git::commits_behind(repo, commit, default_commit) {
Ok(behind) => Settled::Known {
value: behind,
at: Timestamp::now(),
stale: false,
},
Err(error) => Settled::Failed(ProbeError::Base(error.into())),
}
}
fn branch_is_default_branchs_own_row(
repo: &gix::Repository,
branch_name: &str,
default_branch: &DefaultBranch,
) -> bool {
git::tracking_ref_name(repo, branch_name)
.is_some_and(|tracking| tracking.shorten() == default_branch.name())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use super::*;
use crate::cell::Unknown;
use crate::entity::SyncState;
use crate::test_support::{current_branch, 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 open(path: &Path) -> gix::Repository {
gix::open(path).expect("open repo")
}
fn known_default_branch(name: &str) -> Settled<DefaultBranch> {
Settled::Known {
value: DefaultBranch::new(name.into()),
at: Timestamp::now(),
stale: false,
}
}
fn set_default_branch_ref(path: &Path, sha: &str) {
git(path, &["update-ref", "refs/remotes/origin/main", sha]);
}
fn add_remote(path: &Path, name: &str) {
git(
path,
&["remote", "add", name, "https://example.invalid/repo.git"],
);
}
fn configure_upstream(path: &Path, branch: &str, upstream_branch: &str) {
git(
path,
&["config", &format!("branch.{branch}.remote"), "origin"],
);
git(
path,
&[
"config",
&format!("branch.{branch}.merge"),
&format!("refs/heads/{upstream_branch}"),
],
);
}
#[test]
fn a_repo_with_no_remote_settles_base_not_applicable() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let sha = head_sha(&repo);
let head = Head::Branch {
name: "main".into(),
commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
};
let outcome = probe(&open(&repo), &head, &known_default_branch("origin/main"));
assert!(
matches!(outcome, Settled::NotApplicable),
"expected a Repo with no remote to settle base Not applicable, got {outcome:?}"
);
}
#[test]
fn a_repo_with_a_remote_and_an_unrelated_upstream_is_not_exempt() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let root_branch = current_branch(&repo);
git(&repo, &["checkout", "-b", "feature"]);
git(&repo, &["commit", "--allow-empty", "-m", "unmerged work"]);
let feature_sha = head_sha(&repo);
configure_upstream(&repo, "feature", "feature");
git(
&repo,
&["update-ref", "refs/remotes/origin/feature", &feature_sha],
);
git(&repo, &["checkout", &root_branch]);
git(
&repo,
&["commit", "--allow-empty", "-m", "default moved on"],
);
let default_tip_sha = head_sha(&repo);
set_default_branch_ref(&repo, &default_tip_sha);
let head = Head::Branch {
name: "feature".into(),
commit: gix::ObjectId::from_hex(feature_sha.as_bytes()).expect("parse sha"),
};
let outcome = probe(&open(&repo), &head, &known_default_branch("origin/main"));
match outcome {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(value, 1),
other => panic!(
"expected a branch tracking something other than the default branch to \
compute a real count, got {other:?}"
),
}
}
#[test]
fn an_upstream_sitting_on_the_default_branchs_own_commit_is_still_not_the_default_row() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
git(&repo, &["checkout", "-b", "feature"]);
let tip_sha = head_sha(&repo);
configure_upstream(&repo, "feature", "feature");
git(
&repo,
&["update-ref", "refs/remotes/origin/feature", &tip_sha],
);
set_default_branch_ref(&repo, &tip_sha);
let head = Head::Branch {
name: "feature".into(),
commit: gix::ObjectId::from_hex(tip_sha.as_bytes()).expect("parse sha"),
};
let outcome = probe(&open(&repo), &head, &known_default_branch("origin/main"));
match outcome {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(
value, 0,
"a branch level with the default branch is behind it by nothing, which is \
a settled count rather than an exemption"
),
other => panic!(
"expected a real count for a branch tracking its own ref, even one sitting \
on the default branch's commit, got {other:?}"
),
}
}
#[test]
fn a_branch_tracking_the_default_branch_settles_base_not_applicable() {
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_default_branch_ref(&repo, &sha);
let branch_name = current_branch(&repo);
configure_upstream(&repo, &branch_name, "main");
let head = Head::Branch {
name: branch_name.as_str().into(),
commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
};
let outcome = probe(&open(&repo), &head, &known_default_branch("origin/main"));
assert!(
matches!(outcome, Settled::NotApplicable),
"expected the default branch's own row to settle base Not applicable, got {outcome:?}"
);
}
#[test]
fn a_detached_head_gets_a_live_count_and_matches_neither_exemption() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let base_sha = head_sha(&repo);
git(&repo, &["commit", "--allow-empty", "-m", "second"]);
let tip_sha = head_sha(&repo);
set_default_branch_ref(&repo, &tip_sha);
git(&repo, &["checkout", "--detach", &base_sha]);
let head = Head::Detached(gix::ObjectId::from_hex(base_sha.as_bytes()).expect("parse sha"));
let outcome = probe(&open(&repo), &head, &known_default_branch("origin/main"));
match outcome {
Settled::Known {
value,
at: _,
stale: _,
} => assert_eq!(value, 1),
other => panic!("expected a detached HEAD to get a live count, got {other:?}"),
}
}
#[test]
fn base_settles_unknown_when_the_default_branch_is_unknown_even_though_sync_would_succeed() {
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);
configure_upstream(&repo, "main", "main");
git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
let head = Head::Branch {
name: "main".into(),
commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
};
let sync = git::resolve_sync(&open(&repo), Some(&head));
assert!(
matches!(sync, Ok(SyncState::Tracking(_))),
"the fixture must give sync a resolvable upstream, got {sync:?}"
);
let outcome = probe(
&open(&repo),
&head,
&Settled::Unknown(Unknown::NoDefaultBranch),
);
assert!(
matches!(outcome, Settled::Unknown(Unknown::NoDefaultBranch)),
"expected an Unknown default branch to settle base Unknown independently of \
sync's own success, got {outcome:?}"
);
}
#[test]
fn sync_and_base_diverge_off_the_default_branch_and_coincide_only_on_its_own_row() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
add_remote(&repo, "origin");
let root_branch = current_branch(&repo);
git(&repo, &["checkout", "-b", "feature"]);
git(&repo, &["commit", "--allow-empty", "-m", "feature work"]);
let feature_local_sha = head_sha(&repo);
git(
&repo,
&["commit", "--allow-empty", "-m", "feature upstream moved"],
);
let feature_upstream_sha = head_sha(&repo);
configure_upstream(&repo, "feature", "feature");
git(
&repo,
&[
"update-ref",
"refs/remotes/origin/feature",
&feature_upstream_sha,
],
);
git(&repo, &["reset", "--hard", &feature_local_sha]);
git(&repo, &["checkout", &root_branch]);
git(&repo, &["commit", "--allow-empty", "-m", "default moved"]);
git(
&repo,
&["commit", "--allow-empty", "-m", "default moved again"],
);
let default_tip_sha = head_sha(&repo);
set_default_branch_ref(&repo, &default_tip_sha);
configure_upstream(&repo, &root_branch, "main");
git(
&repo,
&["update-ref", "refs/remotes/origin/main", &default_tip_sha],
);
let feature_head = Head::Branch {
name: "feature".into(),
commit: gix::ObjectId::from_hex(feature_local_sha.as_bytes()).expect("parse sha"),
};
let feature_sync = git::resolve_sync(&open(&repo), Some(&feature_head))
.expect("resolve_sync must succeed against a live upstream");
let feature_base = probe(
&open(&repo),
&feature_head,
&known_default_branch("origin/main"),
);
let feature_sync_behind = match feature_sync {
SyncState::Tracking(counts) => counts.behind,
other => panic!("expected feature's sync to be Tracking, got {other:?}"),
};
let feature_base_value = match feature_base {
Settled::Known {
value,
at: _,
stale: _,
} => value,
other => panic!("expected feature's base to be a real count, got {other:?}"),
};
assert_ne!(
feature_sync_behind, feature_base_value,
"sync (behind feature's own upstream) and base (behind the default branch) \
must diverge off the default branch"
);
let root_head = Head::Branch {
name: root_branch.as_str().into(),
commit: gix::ObjectId::from_hex(default_tip_sha.as_bytes()).expect("parse sha"),
};
let root_sync = git::resolve_sync(&open(&repo), Some(&root_head))
.expect("resolve_sync must succeed against a live upstream");
assert!(
matches!(root_sync, SyncState::Tracking(_)),
"expected the default branch's own row's sync to be Tracking, got {root_sync:?}"
);
let root_base = probe(
&open(&repo),
&root_head,
&known_default_branch("origin/main"),
);
assert!(
matches!(root_base, Settled::NotApplicable),
"expected the default branch's own row to elide base rather than repeat sync's \
own number, got {root_base:?}"
);
}
#[test]
fn an_unborn_head_settles_base_not_applicable() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
fs::create_dir_all(&repo).expect("create repo dir");
git(&repo, &["init", "-q"]);
add_remote(&repo, "origin");
let outcome = probe(
&open(&repo),
&Head::Unborn("main".into()),
&known_default_branch("origin/main"),
);
assert!(
matches!(outcome, Settled::NotApplicable),
"expected an unborn HEAD to settle base Not applicable rather than stay \
unsettled, got {outcome:?}"
);
}
#[test]
fn a_failed_default_branch_settles_base_failed_with_the_same_error() {
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);
let head = Head::Detached(gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"));
let outcome = probe(
&open(&repo),
&head,
&Settled::Failed(ProbeError::Open("boom".into())),
);
match outcome {
Settled::Failed(ProbeError::Open(message)) => assert_eq!(&*message, "boom"),
other => {
panic!("expected the default branch's own Failed error to propagate, got {other:?}")
}
}
}
}