use std::collections::HashSet;
use gix::bstr::BString;
use crate::cell::{Settled, Timestamp};
use crate::entity::WorktreeState;
use crate::git::ProbeError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum PatchEntry {
Added {
path: BString,
id: gix::ObjectId,
},
Deleted {
path: BString,
id: gix::ObjectId,
},
Modified {
path: BString,
before: gix::ObjectId,
after: gix::ObjectId,
},
}
impl PatchEntry {
fn path(&self) -> &BString {
match self {
PatchEntry::Added { path, .. }
| PatchEntry::Deleted { path, .. }
| PatchEntry::Modified { path, .. } => path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct PatchIdentity(Vec<PatchEntry>);
pub(crate) type PatchIdentitySet = HashSet<PatchIdentity>;
pub(crate) fn probe(
repo: &gix::Repository,
entity_tip: gix::ObjectId,
merge_base: Option<gix::ObjectId>,
shared: &PatchIdentitySet,
) -> Settled<WorktreeState> {
let Some(merge_base) = merge_base else {
return settle(WorktreeState::Active);
};
match diff_identity(repo, Some(merge_base), entity_tip) {
Ok(identity) if shared.contains(&identity) => settle(WorktreeState::Merged),
Ok(_) => settle(WorktreeState::Active),
Err(error) => Settled::Failed(error),
}
}
fn settle(value: WorktreeState) -> Settled<WorktreeState> {
Settled::Known {
value,
at: Timestamp::now(),
stale: false,
}
}
pub(crate) fn scan_default_branch(
repo: &gix::Repository,
tip: gix::ObjectId,
bound: Option<gix::ObjectId>,
) -> Result<PatchIdentitySet, ProbeError> {
let mut identities = HashSet::new();
let mut current = tip;
loop {
if Some(current) == bound {
break;
}
let parent = first_parent(repo, current)?;
identities.insert(diff_identity(repo, parent, current)?);
match parent {
Some(next) => current = next,
None => break,
}
}
Ok(identities)
}
fn first_parent(
repo: &gix::Repository,
id: gix::ObjectId,
) -> Result<Option<gix::ObjectId>, ProbeError> {
let commit = repo
.find_commit(id)
.map_err(|error| ProbeError::PatchEquivalence(error.to_string().into()))?;
Ok(commit.parent_ids().next().map(|id| id.detach()))
}
fn diff_identity(
repo: &gix::Repository,
from: Option<gix::ObjectId>,
to: gix::ObjectId,
) -> Result<PatchIdentity, ProbeError> {
let from_tree = from.map(|id| commit_tree(repo, id)).transpose()?;
let to_tree = commit_tree(repo, to)?;
let changes = repo
.diff_tree_to_tree(
from_tree.as_ref(),
Some(&to_tree),
gix::diff::Options::default(),
)
.map_err(|error| ProbeError::PatchEquivalence(error.to_string().into()))?;
let mut entries: Vec<PatchEntry> = changes.into_iter().filter_map(to_entry).collect();
entries.sort_by(|a, b| a.path().cmp(b.path()));
Ok(PatchIdentity(entries))
}
fn commit_tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::Tree<'_>, ProbeError> {
repo.find_commit(id)
.map_err(|error| ProbeError::PatchEquivalence(error.to_string().into()))?
.tree()
.map_err(|error| ProbeError::PatchEquivalence(error.to_string().into()))
}
fn to_entry(change: gix::object::tree::diff::ChangeDetached) -> Option<PatchEntry> {
use gix::object::tree::diff::ChangeDetached as Change;
match change {
Change::Addition { location, id, .. } => Some(PatchEntry::Added { path: location, id }),
Change::Deletion { location, id, .. } => Some(PatchEntry::Deleted { path: location, id }),
Change::Modification {
location,
previous_id,
id,
..
} => Some(PatchEntry::Modified {
path: location,
before: previous_id,
after: id,
}),
Change::Rewrite { .. } => None,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use super::*;
use crate::test_support::{git, head_sha, loose_object_count};
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")
}
#[test]
fn a_squash_merged_branchs_diff_matches_the_defaults_squash_commit() {
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, &["checkout", "-b", "feature"]);
fs::write(repo.join("a.txt"), "one\n").expect("write a.txt");
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-m", "add a"]);
fs::write(repo.join("b.txt"), "two\n").expect("write b.txt");
git(&repo, &["add", "b.txt"]);
git(&repo, &["commit", "-m", "add b"]);
let feature_sha = head_sha(&repo);
git(&repo, &["checkout", "-B", "main", &base_sha]);
git(&repo, &["merge", "--squash", "feature"]);
git(&repo, &["commit", "-m", "squashed feature"]);
let main_sha = head_sha(&repo);
let opened = open(&repo);
let shared =
scan_default_branch(&opened, id(&main_sha), None).expect("scan default branch");
let outcome = probe(&opened, id(&feature_sha), Some(id(&base_sha)), &shared);
assert!(
matches!(
outcome,
Settled::Known {
value: WorktreeState::Merged,
at: _,
stale: _
}
),
"expected a cleanly squash-merged branch to settle Merged, got {outcome:?}"
);
}
#[test]
fn probe_diffs_the_entitys_range_from_the_merge_base_it_is_handed() {
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, &["checkout", "-b", "feature"]);
fs::write(repo.join("a.txt"), "one\n").expect("write a.txt");
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-m", "add a"]);
let mid_sha = head_sha(&repo);
fs::write(repo.join("b.txt"), "two\n").expect("write b.txt");
git(&repo, &["add", "b.txt"]);
git(&repo, &["commit", "-m", "add b"]);
let feature_sha = head_sha(&repo);
git(&repo, &["checkout", "-B", "main", &base_sha]);
git(&repo, &["merge", "--squash", "feature"]);
git(&repo, &["commit", "-m", "squashed feature"]);
let main_sha = head_sha(&repo);
let opened = open(&repo);
let shared =
scan_default_branch(&opened, id(&main_sha), None).expect("scan default branch");
let from_the_fork_point = probe(&opened, id(&feature_sha), Some(id(&base_sha)), &shared);
let from_mid_branch = probe(&opened, id(&feature_sha), Some(id(&mid_sha)), &shared);
assert!(
matches!(
from_the_fork_point,
Settled::Known {
value: WorktreeState::Merged,
at: _,
stale: _
}
),
"expected the fork point to yield the whole squashed range, got {from_the_fork_point:?}"
);
assert!(
matches!(
from_mid_branch,
Settled::Known {
value: WorktreeState::Active,
at: _,
stale: _
}
),
"expected a base halfway along the branch to yield only b.txt, which the squash commit does not match, got {from_mid_branch:?}"
);
}
#[test]
fn a_genuinely_unmerged_branch_never_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, &["checkout", "-b", "feature"]);
fs::write(repo.join("a.txt"), "one\n").expect("write a.txt");
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-m", "add a"]);
let feature_sha = head_sha(&repo);
git(&repo, &["checkout", "-B", "main", &base_sha]);
fs::write(repo.join("unrelated.txt"), "unrelated\n").expect("write unrelated.txt");
git(&repo, &["add", "unrelated.txt"]);
git(&repo, &["commit", "-m", "unrelated change on main"]);
let main_sha = head_sha(&repo);
let opened = open(&repo);
let shared =
scan_default_branch(&opened, id(&main_sha), None).expect("scan default branch");
let outcome = probe(&opened, id(&feature_sha), Some(id(&base_sha)), &shared);
assert!(
!matches!(
outcome,
Settled::Known {
value: WorktreeState::Merged,
at: _,
stale: _
}
),
"expected genuinely unmerged work to never settle Merged, got {outcome:?}"
);
}
#[test]
fn scanning_and_probing_write_no_loose_objects() {
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, &["checkout", "-b", "feature"]);
fs::write(repo.join("a.txt"), "one\n").expect("write a.txt");
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-m", "add a"]);
let feature_sha = head_sha(&repo);
git(&repo, &["checkout", "-B", "main", &base_sha]);
git(&repo, &["merge", "--squash", "feature"]);
git(&repo, &["commit", "-m", "squashed feature"]);
let main_sha = head_sha(&repo);
let before = loose_object_count(&repo);
let opened = open(&repo);
let shared =
scan_default_branch(&opened, id(&main_sha), None).expect("scan default branch");
let _ = probe(&opened, id(&feature_sha), Some(id(&base_sha)), &shared);
let after = loose_object_count(&repo);
assert_eq!(
before, after,
"patch equivalence must never write a loose object to the repository"
);
}
#[test]
fn scanning_with_a_bound_never_diffs_a_commit_at_or_before_it() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
fs::write(repo.join("poison.txt"), "never scanned\n").expect("write poison.txt");
git(&repo, &["add", "poison.txt"]);
git(&repo, &["commit", "-m", "poison, older than the bound"]);
let bound_sha = head_sha(&repo);
fs::write(repo.join("a.txt"), "one\n").expect("write a.txt");
git(&repo, &["add", "a.txt"]);
git(&repo, &["commit", "-m", "since the bound, one"]);
fs::write(repo.join("b.txt"), "two\n").expect("write b.txt");
git(&repo, &["add", "b.txt"]);
git(&repo, &["commit", "-m", "since the bound, two"]);
let tip_sha = head_sha(&repo);
let opened = open(&repo);
let identities = scan_default_branch(&opened, id(&tip_sha), Some(id(&bound_sha)))
.expect("scan default branch");
assert_eq!(
identities.len(),
2,
"expected exactly the two commits since (not including) the bound"
);
assert!(
identities
.iter()
.flat_map(|identity| identity.0.iter())
.all(|entry| entry.path() != "poison.txt"),
"a commit at or before the bound must never be diffed, but poison.txt appeared \
in a returned identity"
);
}
#[test]
fn unrelated_histories_settle_active_not_failed() {
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 opened = open(&repo);
let shared = scan_default_branch(&opened, id(&one), None).expect("scan default branch");
let outcome = probe(&opened, id(&two), None, &shared);
assert!(
matches!(
outcome,
Settled::Known {
value: WorktreeState::Active,
at: _,
stale: _
}
),
"expected two unrelated histories to settle Active, not Failed, got {outcome:?}"
);
}
#[test]
fn a_deleted_commit_object_settles_failed_not_a_confident_answer() {
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);
let (dir_name, file_name) = tip_sha.split_at(2);
let object_path = repo
.join(".git")
.join("objects")
.join(dir_name)
.join(file_name);
assert!(
object_path.exists(),
"expected a loose object at {object_path:?}"
);
fs::remove_file(&object_path).expect("delete loose object");
let opened = open(&repo);
let shared = HashSet::new();
let outcome = probe(&opened, id(&tip_sha), Some(id(&base_sha)), &shared);
assert!(
matches!(outcome, Settled::Failed(ProbeError::PatchEquivalence(_))),
"expected a missing commit object to settle Failed, got {outcome:?}"
);
}
fn build_deep_history(repo: &Path, depth: usize, from_sha: &str) -> String {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("git")
.arg("-C")
.arg(repo)
.args(["fast-import", "--quiet"])
.stdin(Stdio::piped())
.spawn()
.expect("spawn git fast-import");
{
let stdin = child.stdin.as_mut().expect("fast-import stdin");
for i in 1..=depth {
let message = format!("filler commit {i}");
let content = format!("content {i}\n");
write!(
stdin,
"commit refs/heads/main\n\
mark :{mark}\n\
committer Test <test@example.com> {when} +0000\n\
data {mlen}\n\
{message}\n",
mark = i,
when = 1_700_000_000 + i,
mlen = message.len() + 1,
)
.expect("write commit header");
if i == 1 {
writeln!(stdin, "from {from_sha}").expect("write from");
}
write!(
stdin,
"M 100644 inline changing.txt\ndata {clen}\n",
clen = content.len(),
)
.expect("write file-change header");
stdin
.write_all(content.as_bytes())
.expect("write inline content");
}
}
let status = child.wait().expect("wait for fast-import");
assert!(status.success(), "git fast-import failed");
head_sha(repo)
}
#[test]
#[ignore = "hand-run measurement; see the ticket's report for recorded figures"]
fn bounding_the_scan_measurably_shortens_a_deep_history() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("repo");
init_repo_with_a_commit(&repo);
git(&repo, &["checkout", "-B", "main"]);
let from_sha = head_sha(&repo);
let depth = 5_000;
let bound_sha = build_deep_history(&repo, depth, &from_sha);
git(&repo, &["checkout", "-f", "main"]);
fs::write(repo.join("after-a.txt"), "one\n").expect("write after-a.txt");
git(&repo, &["add", "after-a.txt"]);
git(&repo, &["commit", "-m", "since the bound, one"]);
fs::write(repo.join("after-b.txt"), "two\n").expect("write after-b.txt");
git(&repo, &["add", "after-b.txt"]);
git(&repo, &["commit", "-m", "since the bound, two"]);
let tip_sha = head_sha(&repo);
let opened = open(&repo);
let unbounded_started = std::time::Instant::now();
let unbounded = scan_default_branch(&opened, id(&tip_sha), None).expect("unbounded scan");
let unbounded_elapsed = unbounded_started.elapsed();
let bounded_started = std::time::Instant::now();
let bounded =
scan_default_branch(&opened, id(&tip_sha), Some(id(&bound_sha))).expect("bounded scan");
let bounded_elapsed = bounded_started.elapsed();
println!("fixture depth (filler commits before the bound): {depth}");
println!(
"unbounded scan: {unbounded_elapsed:?} over {} commits",
unbounded.len()
);
println!(
"bounded scan: {bounded_elapsed:?} over {} commits",
bounded.len()
);
assert_eq!(
bounded.len(),
2,
"the bounded scan must visit only the two commits since the bound"
);
assert!(
unbounded.len() > bounded.len(),
"the fixture must be deep enough for the unbounded walk to visit strictly more \
commits than the bounded one"
);
}
}