use crate::cell::{Settled, Timestamp};
use crate::entity::{DefaultBranch, WorktreeState};
use crate::git::{self, ProbeError};
pub(crate) enum Outcome {
Settle(Settled<WorktreeState>),
Outstanding(Outstanding),
}
pub(crate) struct Outstanding {
pub(crate) entity_tip: gix::ObjectId,
pub(crate) default_tip: gix::ObjectId,
pub(crate) merge_base: Option<gix::ObjectId>,
}
pub(crate) fn probe(repo: &gix::Repository, default_branch: &Settled<DefaultBranch>) -> Outcome {
let default_branch = match default_branch {
Settled::Known {
value,
at: _,
stale: _,
} => value,
Settled::Unknown(reason) => return Outcome::Settle(Settled::Unknown(*reason)),
Settled::Failed(error) => return Outcome::Settle(Settled::Failed(error.clone())),
Settled::NotApplicable => return Outcome::Settle(Settled::NotApplicable),
};
let head = match repo.head() {
Ok(head) => head,
Err(error) => {
return Outcome::Settle(Settled::Failed(ProbeError::Read(error.to_string().into())));
}
};
let Some(commit) = head.id() else {
return Outcome::Settle(Settled::NotApplicable);
};
let commit = commit.detach();
let default_commit = match resolve_ref_commit(repo, default_branch.name()) {
Ok(id) => id,
Err(error) => return Outcome::Settle(Settled::Failed(error)),
};
let base = match merge_base(repo, commit, default_commit) {
Ok(base) => base,
Err(error) => return Outcome::Settle(Settled::Failed(error)),
};
if base == Some(commit) {
return settle_known(WorktreeState::Merged);
}
if head.is_detached() {
return Outcome::Settle(Settled::NotApplicable);
}
classify_unmerged_branch(
repo,
head,
Outstanding {
entity_tip: commit,
default_tip: default_commit,
merge_base: base,
},
)
}
fn classify_unmerged_branch(
repo: &gix::Repository,
head: gix::Head,
outstanding: Outstanding,
) -> Outcome {
let Some(reference) = head.try_into_referent() else {
return Outcome::Outstanding(outstanding);
};
match reference.remote_ref_name(gix::remote::Direction::Fetch) {
None => settle_known(WorktreeState::LocalOnly),
Some(Err(error)) => Outcome::Settle(Settled::Failed(ProbeError::Ancestry(
error.to_string().into(),
))),
Some(Ok(_)) => match reference.remote_tracking_ref_name(gix::remote::Direction::Fetch) {
None => settle_known(WorktreeState::Gone),
Some(Err(error)) => Outcome::Settle(Settled::Failed(ProbeError::Ancestry(
error.to_string().into(),
))),
Some(Ok(tracking_name)) => {
match repo.try_find_reference(tracking_name.to_string().as_str()) {
Ok(Some(_)) => Outcome::Outstanding(outstanding),
Ok(None) => settle_known(WorktreeState::Gone),
Err(error) => Outcome::Settle(Settled::Failed(ProbeError::Ancestry(
error.to_string().into(),
))),
}
}
},
}
}
fn settle_known(value: WorktreeState) -> Outcome {
Outcome::Settle(Settled::Known {
value,
at: Timestamp::now(),
stale: false,
})
}
fn merge_base(
repo: &gix::Repository,
commit: gix::ObjectId,
ancestor_of: gix::ObjectId,
) -> Result<Option<gix::ObjectId>, ProbeError> {
git::checked_merge_base(repo, commit, ancestor_of)
.map_err(|error| ProbeError::Ancestry(error.into()))
}
pub(crate) fn resolve_ref_commit(
repo: &gix::Repository,
name: &str,
) -> Result<gix::ObjectId, ProbeError> {
let candidates = [format!("refs/remotes/{name}"), name.to_string()];
for candidate in candidates {
if let Some(mut reference) = repo.try_find_reference(candidate.as_str()).ok().flatten() {
return reference
.peel_to_id()
.map(|id| id.detach())
.map_err(|error| ProbeError::Ancestry(error.to_string().into()));
}
}
Err(ProbeError::Ancestry(
format!("resolved default branch ref does not exist: {name}").into(),
))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use super::*;
use crate::cell::Unknown;
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 open(path: &Path) -> gix::Repository {
gix::open(path).expect("open repo")
}
fn id(sha: &str) -> gix::ObjectId {
gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
}
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 configure_upstream(path: &Path, branch: &str) {
git(
path,
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
);
git(
path,
&["config", &format!("branch.{branch}.remote"), "origin"],
);
git(
path,
&[
"config",
&format!("branch.{branch}.merge"),
&format!("refs/heads/{branch}"),
],
);
}
#[test]
fn a_branch_at_the_same_commit_as_the_default_branch_settles_merged() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let sha = head_sha(&repo);
set_default_branch_ref(&repo, &sha);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
match outcome {
Outcome::Settle(Settled::Known {
value: WorktreeState::Merged,
at: _,
stale: _,
}) => {}
_ => panic!("expected the identical commit to settle Merged"),
}
}
#[test]
fn a_branch_strictly_behind_the_default_branch_settles_merged_by_ancestry() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
git(&repo, &["branch", "feature"]);
git(&repo, &["commit", "--allow-empty", "-m", "second"]);
let tip_sha = head_sha(&repo);
set_default_branch_ref(&repo, &tip_sha);
git(&repo, &["checkout", "feature"]);
assert_eq!(
head_sha(&repo),
base_sha,
"feature must stay at the fork point"
);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
match outcome {
Outcome::Settle(Settled::Known {
value: WorktreeState::Merged,
at: _,
stale: _,
}) => {}
_ => panic!("expected an ancestor commit to settle Merged"),
}
}
#[test]
fn a_diverged_attached_branch_with_a_live_upstream_stays_outstanding_rather_than_settling_gone()
{
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
set_default_branch_ref(&repo, &base_sha);
git(&repo, &["checkout", "-b", "feature"]);
git(&repo, &["commit", "--allow-empty", "-m", "unmerged work"]);
let feature_sha = head_sha(&repo);
configure_upstream(&repo, "feature");
git(
&repo,
&["update-ref", "refs/remotes/origin/feature", &feature_sha],
);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
assert!(
matches!(outcome, Outcome::Outstanding(_)),
"an attached branch with a live upstream ancestry says no for must stay Outstanding, never settle"
);
}
#[test]
fn an_outstanding_outcome_carries_the_commit_pair_and_merge_base_ancestry_already_walked() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let fork_point = head_sha(&repo);
git(&repo, &["commit", "--allow-empty", "-m", "main moves on"]);
let main_sha = head_sha(&repo);
set_default_branch_ref(&repo, &main_sha);
git(&repo, &["checkout", "-b", "feature", &fork_point]);
git(&repo, &["commit", "--allow-empty", "-m", "unmerged work"]);
let feature_sha = head_sha(&repo);
configure_upstream(&repo, "feature");
git(
&repo,
&["update-ref", "refs/remotes/origin/feature", &feature_sha],
);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
let Outcome::Outstanding(outstanding) = outcome else {
panic!("a diverged branch with a live upstream must stay Outstanding");
};
assert_eq!(outstanding.entity_tip, id(&feature_sha), "entity tip");
assert_eq!(outstanding.default_tip, id(&main_sha), "default tip");
assert_eq!(outstanding.merge_base, Some(id(&fork_point)), "merge base");
}
#[test]
fn an_attached_branch_with_no_upstream_settles_local_only() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
set_default_branch_ref(&repo, &base_sha);
git(&repo, &["checkout", "-b", "feature"]);
git(&repo, &["commit", "--allow-empty", "-m", "unmerged work"]);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
match outcome {
Outcome::Settle(Settled::Known {
value: WorktreeState::LocalOnly,
at: _,
stale: _,
}) => {}
_ => panic!("expected a branch with no upstream at all to settle Local only"),
}
}
#[test]
fn an_attached_branch_whose_upstream_no_longer_resolves_settles_gone() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
set_default_branch_ref(&repo, &base_sha);
git(&repo, &["checkout", "-b", "feature"]);
git(&repo, &["commit", "--allow-empty", "-m", "unmerged work"]);
configure_upstream(&repo, "feature");
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
match outcome {
Outcome::Settle(Settled::Known {
value: WorktreeState::Gone,
at: _,
stale: _,
}) => {}
_ => panic!("expected a configured but unresolved upstream to settle Gone"),
}
}
#[test]
fn a_diverged_detached_head_settles_not_applicable_rather_than_staying_outstanding_forever() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
set_default_branch_ref(&repo, &base_sha);
git(&repo, &["checkout", "--detach", &base_sha]);
git(
&repo,
&["commit", "--allow-empty", "-m", "unmerged detached work"],
);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
assert!(
matches!(outcome, Outcome::Settle(Settled::NotApplicable)),
"a detached HEAD that is not an ancestor must settle Not applicable, not stay Outstanding or gain a fifth state"
);
}
#[test]
fn a_detached_head_that_is_an_ancestor_still_settles_merged() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
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 outcome = probe(&open(&repo), &known_default_branch("origin/main"));
match outcome {
Outcome::Settle(Settled::Known {
value: WorktreeState::Merged,
at: _,
stale: _,
}) => {}
_ => panic!("expected a detached HEAD that is an ancestor to settle Merged"),
}
}
#[test]
fn an_unborn_head_settles_state_not_applicable_with_no_commit_to_prove_ancestry_from() {
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"]);
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
assert!(
matches!(outcome, Outcome::Settle(Settled::NotApplicable)),
"expected an unborn HEAD to settle state Not applicable rather than stay \
Outstanding, got a shape that does not match"
);
}
#[test]
fn an_unknown_default_branch_settles_state_unknown_too() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let outcome = probe(&open(&repo), &Settled::Unknown(Unknown::NoDefaultBranch));
assert!(matches!(
outcome,
Outcome::Settle(Settled::Unknown(Unknown::NoDefaultBranch))
));
}
#[test]
fn a_failed_default_branch_settles_state_failed_with_the_same_error() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let outcome = probe(
&open(&repo),
&Settled::Failed(ProbeError::Open("boom".into())),
);
match outcome {
Outcome::Settle(Settled::Failed(ProbeError::Open(message))) => {
assert_eq!(&*message, "boom");
}
_ => panic!("expected the default branch's own Failed error to propagate"),
}
}
#[test]
fn unrelated_histories_with_no_common_ancestor_read_as_not_an_ancestor_not_a_failure() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let one = head_sha(&repo);
git(&repo, &["checkout", "--orphan", "unrelated"]);
git(&repo, &["commit", "--allow-empty", "-m", "unrelated root"]);
let two = head_sha(&repo);
let result = merge_base(
&open(&repo),
gix::ObjectId::from_hex(two.as_bytes()).expect("parse sha"),
gix::ObjectId::from_hex(one.as_bytes()).expect("parse sha"),
);
assert!(
matches!(result, Ok(None)),
"two unrelated roots must have no merge base, not an error, got {result:?}"
);
}
#[test]
fn a_corrupt_commit_object_is_a_real_failure_not_a_confident_no() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
git(&repo, &["commit", "--allow-empty", "-m", "second"]);
let tip_sha = head_sha(&repo);
corrupt_loose_object(&repo, &tip_sha);
let result = merge_base(
&open(&repo),
gix::ObjectId::from_hex(tip_sha.as_bytes()).expect("parse sha"),
gix::ObjectId::from_hex(base_sha.as_bytes()).expect("parse sha"),
);
assert!(
matches!(result, Err(ProbeError::Ancestry(_))),
"a corrupt commit object must be an Ancestry error, got {result:?}"
);
}
#[test]
fn a_deleted_commit_object_is_a_real_failure_not_a_confident_no() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let base_sha = head_sha(&repo);
git(&repo, &["commit", "--allow-empty", "-m", "second"]);
let tip_sha = head_sha(&repo);
delete_loose_object(&repo, &tip_sha);
let result = merge_base(
&open(&repo),
gix::ObjectId::from_hex(tip_sha.as_bytes()).expect("parse sha"),
gix::ObjectId::from_hex(base_sha.as_bytes()).expect("parse sha"),
);
assert!(
matches!(result, Err(ProbeError::Ancestry(_))),
"a deleted commit object must be an error, got {result:?}"
);
}
#[test]
fn an_unreadable_head_settles_failed_rather_than_a_worktree_state() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
set_default_branch_ref(&repo, &head_sha(&repo));
fs::write(
repo.join(".git").join("HEAD"),
"not a ref or an object id\n",
)
.expect("corrupt HEAD");
let outcome = probe(&open(&repo), &known_default_branch("origin/main"));
match outcome {
Outcome::Settle(Settled::Failed(ProbeError::Read(_))) => {}
_ => panic!("expected an unreadable HEAD to settle Failed"),
}
}
#[test]
fn a_default_branch_name_that_resolves_to_no_ref_at_all_is_an_error() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
let result = resolve_ref_commit(&open(&repo), "origin/does-not-exist");
assert!(
matches!(result, Err(ProbeError::Ancestry(_))),
"a name with no matching ref at all must be an error, got {result:?}"
);
}
fn corrupt_loose_object(repo: &Path, sha: &str) {
let (dir, file) = sha.split_at(2);
let path = repo.join(".git").join("objects").join(dir).join(file);
assert!(path.exists(), "expected a loose object at {path:?}");
let mut permissions = fs::metadata(&path)
.expect("stat loose object")
.permissions();
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
fs::set_permissions(&path, permissions).expect("make loose object writable");
fs::write(&path, b"not a valid zlib stream").expect("corrupt loose object");
}
fn delete_loose_object(repo: &Path, sha: &str) {
let (dir, file) = sha.split_at(2);
let path = repo.join(".git").join("objects").join(dir).join(file);
assert!(path.exists(), "expected a loose object at {path:?}");
fs::remove_file(&path).expect("delete loose object");
}
}