use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::entity::{AheadBehind, DirtyCounts, Head, Kind, SyncState};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ProbeError {
Open(Arc<str>),
Read(Arc<str>),
Submodules(Arc<str>),
Ancestry(Arc<str>),
PatchEquivalence(Arc<str>),
AheadBehind(Arc<str>),
Base(Arc<str>),
Status(Arc<str>),
Unpushed(Arc<str>),
IgnoredDirectories(Arc<str>),
}
impl std::fmt::Display for ProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProbeError::Open(message) => write!(f, "failed to open git repository: {message}"),
ProbeError::Read(message) => write!(f, "failed to read HEAD: {message}"),
ProbeError::Submodules(message) => write!(f, "failed to read .gitmodules: {message}"),
ProbeError::Ancestry(message) => write!(f, "failed to check ancestry: {message}"),
ProbeError::PatchEquivalence(message) => {
write!(f, "failed to check patch equivalence: {message}")
}
ProbeError::AheadBehind(message) => {
write!(f, "failed to compute ahead/behind counts: {message}")
}
ProbeError::Base(message) => {
write!(
f,
"failed to compute the behind-the-default-branch count: {message}"
)
}
ProbeError::Status(message) => write!(f, "failed to read status: {message}"),
ProbeError::Unpushed(message) => {
write!(f, "failed to count unpushed commits: {message}")
}
ProbeError::IgnoredDirectories(message) => {
write!(f, "failed to enumerate ignored directories: {message}")
}
}
}
}
impl std::error::Error for ProbeError {}
pub(crate) fn checked_merge_base(
repo: &gix::Repository,
a: gix::ObjectId,
b: gix::ObjectId,
) -> Result<Option<gix::ObjectId>, String> {
if a == b {
return Ok(Some(a));
}
for id in [a, b] {
if !repo.has_object(id) {
return Err(format!("commit object not found: {id}"));
}
}
match repo.merge_base(a, b) {
Ok(base) => Ok(Some(base.detach())),
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
Err(other) => Err(other.to_string()),
}
}
pub(crate) fn has_any_remote(repo: &gix::Repository) -> bool {
!repo.remote_names().is_empty()
}
fn commits_unique_to(
repo: &gix::Repository,
tip: gix::ObjectId,
hidden: gix::ObjectId,
) -> Result<u32, String> {
if tip == hidden {
return Ok(0);
}
for id in [tip, hidden] {
if !repo.has_object(id) {
return Err(format!("commit object not found: {id}"));
}
}
let walk = repo
.rev_walk([tip])
.with_hidden([hidden])
.all()
.map_err(|error| error.to_string())?;
let mut count = 0u32;
for info in walk {
info.map_err(|error| error.to_string())?;
count += 1;
}
Ok(count)
}
pub(crate) fn ahead_behind(
repo: &gix::Repository,
branch: gix::ObjectId,
upstream: gix::ObjectId,
) -> Result<AheadBehind, String> {
Ok(AheadBehind {
ahead: commits_unique_to(repo, branch, upstream)?,
behind: commits_unique_to(repo, upstream, branch)?,
})
}
pub(crate) fn tracking_ref_name(
repo: &gix::Repository,
branch_name: &str,
) -> Option<gix::refs::FullName> {
let full_name = gix::refs::FullName::try_from(format!("refs/heads/{branch_name}")).ok()?;
repo.branch_remote_tracking_ref_name(full_name.as_ref(), gix::remote::Direction::Fetch)?
.ok()
}
pub(crate) fn upstream_commit(repo: &gix::Repository, branch_name: &str) -> Option<gix::ObjectId> {
let tracking_ref_name = tracking_ref_name(repo, branch_name)?;
let mut reference = repo.find_reference(tracking_ref_name.as_ref()).ok()?;
reference.peel_to_id().ok().map(|id| id.detach())
}
pub(crate) fn commits_behind(
repo: &gix::Repository,
commit: gix::ObjectId,
default_commit: gix::ObjectId,
) -> Result<u32, String> {
commits_unique_to(repo, default_commit, commit)
}
pub(crate) fn resolve_sync(
repo: &gix::Repository,
head: Option<&Head>,
) -> Result<SyncState, ProbeError> {
if !has_any_remote(repo) {
return Ok(SyncState::NoRemote);
}
let Some(Head::Branch { name, commit }) = head else {
return Ok(SyncState::NoUpstream);
};
let Some(upstream) = upstream_commit(repo, name) else {
return Ok(SyncState::NoUpstream);
};
ahead_behind(repo, *commit, upstream)
.map(SyncState::Tracking)
.map_err(|error| ProbeError::AheadBehind(error.into()))
}
pub(crate) fn unpushed(repo: &gix::Repository) -> Result<(u32, u32), ProbeError> {
let remote_tips = branch_tips(repo, Branches::Remote)?;
let local_tips = branch_tips(repo, Branches::Local)?;
if local_tips.is_empty() {
return Ok((0, 0));
}
let mut branches = 0u32;
for tip in &local_tips {
if commits_not_carried_by(repo, &[*tip], &remote_tips)? > 0 {
branches += 1;
}
}
let commits = commits_not_carried_by(repo, &local_tips, &remote_tips)?;
Ok((commits, branches))
}
#[derive(Debug, Clone, Copy)]
enum Branches {
Local,
Remote,
}
fn commits_not_carried_by(
repo: &gix::Repository,
tips: &[gix::ObjectId],
hidden: &[gix::ObjectId],
) -> Result<u32, ProbeError> {
let walk = repo
.rev_walk(tips.iter().copied())
.with_hidden(hidden.iter().copied())
.all()
.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
let mut count = 0u32;
for info in walk {
info.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
count += 1;
}
Ok(count)
}
fn branch_tips(repo: &gix::Repository, which: Branches) -> Result<Vec<gix::ObjectId>, ProbeError> {
let platform = repo
.references()
.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
let iter = match which {
Branches::Local => platform.local_branches(),
Branches::Remote => platform.remote_branches(),
}
.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
let mut tips = Vec::new();
for reference in iter {
let mut reference =
reference.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
if let Ok(id) = reference.peel_to_id() {
tips.push(id.detach());
}
}
Ok(tips)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum InProgressOperation {
ApplyMailbox,
ApplyMailboxRebase,
Bisect,
CherryPick,
CherryPickSequence,
Merge,
Rebase,
RebaseInteractive,
Revert,
RevertSequence,
}
pub(crate) fn in_progress_operation(repo: &gix::Repository) -> Option<InProgressOperation> {
match repo.state()? {
gix::state::InProgress::ApplyMailbox => Some(InProgressOperation::ApplyMailbox),
gix::state::InProgress::ApplyMailboxRebase => Some(InProgressOperation::ApplyMailboxRebase),
gix::state::InProgress::Bisect => Some(InProgressOperation::Bisect),
gix::state::InProgress::CherryPick => Some(InProgressOperation::CherryPick),
gix::state::InProgress::CherryPickSequence => Some(InProgressOperation::CherryPickSequence),
gix::state::InProgress::Merge => Some(InProgressOperation::Merge),
gix::state::InProgress::Rebase => Some(InProgressOperation::Rebase),
gix::state::InProgress::RebaseInteractive => Some(InProgressOperation::RebaseInteractive),
gix::state::InProgress::Revert => Some(InProgressOperation::Revert),
gix::state::InProgress::RevertSequence => Some(InProgressOperation::RevertSequence),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct RecentCommit {
pub short_id: Arc<str>,
pub summary: Arc<str>,
}
pub(crate) fn recent_commits(repo: &gix::Repository, limit: usize) -> Vec<RecentCommit> {
let Ok(head_commit) = repo.head_commit() else {
return Vec::new();
};
let Ok(walk) = head_commit.id().ancestors().all() else {
return Vec::new();
};
let mut commits = Vec::new();
for info in walk.take(limit) {
let Ok(info) = info else { break };
let short_id = info.id.to_string().chars().take(7).collect::<String>();
let summary = repo
.find_object(info.id)
.ok()
.and_then(|object| object.try_into_commit().ok())
.and_then(|commit| {
commit
.message()
.ok()
.map(|message| message.summary().to_string())
})
.unwrap_or_default();
commits.push(RecentCommit {
short_id: Arc::from(short_id),
summary: Arc::from(summary),
});
}
commits
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubmoduleEntry {
pub name: Arc<str>,
pub relative_path: PathBuf,
}
pub(crate) struct Resolved {
pub kind: Kind,
pub common_dir: Arc<Path>,
pub submodules: Result<Vec<SubmoduleEntry>, ProbeError>,
pub repo: gix::ThreadSafeRepository,
}
pub(crate) fn resolve_from_open(repo: gix::Repository) -> Resolved {
let kind = match repo.kind() {
gix::repository::Kind::LinkedWorkTree => Kind::Worktree,
gix::repository::Kind::Common | gix::repository::Kind::Submodule => Kind::Repo,
};
let common_dir = repo.common_dir();
let common_dir: Arc<Path> =
Arc::from(std::fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf()));
let submodules = read_gitmodules(&repo).map(|entries| entries.unwrap_or_default());
Resolved {
kind,
common_dir,
submodules,
repo: repo.into_sync(),
}
}
pub(crate) fn resolve_boundary(path: &Path) -> Result<Resolved, ProbeError> {
let repo = gix::open(path).map_err(|error| ProbeError::Open(error.to_string().into()))?;
Ok(resolve_from_open(repo))
}
pub(crate) fn common_dir_of(path: &Path) -> Result<Arc<Path>, ProbeError> {
let repo = gix::open(path).map_err(|error| ProbeError::Open(error.to_string().into()))?;
let common_dir = repo.common_dir();
Ok(Arc::from(
std::fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf()),
))
}
const OBJECT_CACHE_BYTES: usize = 4 * 1024 * 1024;
pub(crate) fn open_thread_safe(path: &Path) -> Result<gix::ThreadSafeRepository, ProbeError> {
let options = gix::open::Options::default()
.config_overrides([format!("gitoxide.objects.cacheLimit={OBJECT_CACHE_BYTES}")]);
gix::ThreadSafeRepository::open_opts(path, options)
.map_err(|error| ProbeError::Open(error.to_string().into()))
}
fn read_gitmodules(repo: &gix::Repository) -> Result<Option<Vec<SubmoduleEntry>>, ProbeError> {
let Some(modules) = repo
.open_modules_file()
.map_err(|error| ProbeError::Submodules(error.to_string().into()))?
else {
return Ok(None);
};
let mut entries = Vec::new();
for name in modules.names() {
let relative_path = modules
.path(name)
.map_err(|error| ProbeError::Submodules(error.to_string().into()))?;
entries.push(SubmoduleEntry {
name: Arc::from(name.to_string()),
relative_path: gix::path::from_bstring(relative_path),
});
}
Ok(Some(entries))
}
pub fn head_shape(repo: &gix::Repository) -> Result<Head, ProbeError> {
let head = repo
.head()
.map_err(|error| ProbeError::Read(error.to_string().into()))?;
let commit = head.id().map(|id| id.detach());
Ok(match head.kind {
gix::head::Kind::Symbolic(reference) => {
let Some(commit) = commit else {
return Err(ProbeError::Read(
"attached HEAD resolved no commit".to_string().into(),
));
};
Head::Branch {
name: Arc::from(reference.name.shorten().to_string()),
commit,
}
}
gix::head::Kind::Unborn(name) => Head::Unborn(Arc::from(name.shorten().to_string())),
gix::head::Kind::Detached { target, peeled } => Head::Detached(peeled.unwrap_or(target)),
})
}
pub(crate) fn dirty_counts(
repo: &gix::Repository,
cancel: Arc<AtomicBool>,
) -> Result<DirtyCounts, ProbeError> {
let platform = repo
.status(gix::progress::Discard)
.map_err(|error| ProbeError::Status(error.to_string().into()))?
.index_worktree_options_mut(|options| options.thread_limit = Some(1))
.should_interrupt_owned(cancel);
let iter = platform
.into_index_worktree_iter(Vec::new())
.map_err(|error| ProbeError::Status(error.to_string().into()))?;
let mut counts = DirtyCounts::default();
for item in iter {
let item = item.map_err(|error| ProbeError::Status(error.to_string().into()))?;
classify_index_worktree_item(&item, &mut counts);
}
Ok(counts)
}
pub(crate) fn linked_worktrees(repo: &gix::Repository) -> Result<u32, ProbeError> {
repo.worktrees()
.map(|worktrees| worktrees.len() as u32)
.map_err(|error| ProbeError::Read(error.to_string().into()))
}
pub(crate) fn linked_worktree_paths(repo: &gix::Repository) -> Result<Vec<PathBuf>, ProbeError> {
Ok(repo
.worktrees()
.map_err(|error| ProbeError::Read(error.to_string().into()))?
.into_iter()
.filter_map(|worktree| worktree.base().ok())
.collect())
}
pub(crate) fn worktree_admin_dir(repo: &gix::Repository) -> PathBuf {
repo.git_dir().to_path_buf()
}
pub(crate) fn ignored_directories_for_deletion(
repo: &gix::Repository,
) -> Result<Vec<PathBuf>, ProbeError> {
if repo.workdir().is_none() {
return Ok(Vec::new());
}
let index = repo
.index_or_load_from_head_or_empty()
.map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?;
let options = repo
.dirwalk_options()
.map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?
.emit_ignored(Some(gix::dir::walk::EmissionMode::CollapseDirectory))
.for_deletion(Some(
gix::dir::walk::ForDeletionMode::IgnoredDirectoriesCanHideNestedRepositories,
));
let should_interrupt = AtomicBool::new(false);
let mut ignored = IgnoredEntries::default();
let outcome = repo
.dirwalk(
&index,
Vec::<&str>::new(),
&should_interrupt,
options,
&mut ignored,
)
.map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?;
Ok(ignored
.rela_paths
.into_iter()
.map(|rela_path| {
outcome
.traversal_root
.join(gix::path::from_bstring(rela_path))
})
.collect())
}
#[derive(Default)]
struct IgnoredEntries {
rela_paths: Vec<gix::bstr::BString>,
}
impl gix::dir::walk::Delegate for IgnoredEntries {
fn emit(
&mut self,
entry: gix::dir::EntryRef<'_>,
_collapsed_directory_status: Option<gix::dir::entry::Status>,
) -> gix::dir::walk::Action {
if matches!(entry.status, gix::dir::entry::Status::Ignored(_)) {
self.rela_paths.push(entry.rela_path.into_owned());
}
std::ops::ControlFlow::Continue(())
}
}
pub(crate) fn staged_changes(repo: &gix::Repository) -> Result<bool, ProbeError> {
let head_tree = repo
.head_tree_id_or_empty()
.map_err(|error| ProbeError::Status(error.to_string().into()))?;
let index = repo
.index_or_empty()
.map_err(|error| ProbeError::Status(error.to_string().into()))?;
let mut staged = false;
repo.tree_index_status(
&head_tree,
&index,
None,
gix::status::tree_index::TrackRenames::Disabled,
|_, _, _| {
staged = true;
Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(()))
},
)
.map_err(|error| ProbeError::Status(error.to_string().into()))?;
Ok(staged)
}
fn classify_index_worktree_item(
item: &gix::status::index_worktree::Item,
counts: &mut DirtyCounts,
) {
use gix::status::index_worktree::Item;
use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
match item {
Item::Modification { status, .. } => match status {
EntryStatus::Conflict { .. } => counts.modified += 1,
EntryStatus::Change(change) => match change {
Change::Removed => counts.deleted += 1,
Change::Type { .. } => counts.modified += 1,
Change::Modification { .. } => counts.modified += 1,
Change::SubmoduleModification(_) => counts.modified += 1,
},
EntryStatus::NeedsUpdate(_) | EntryStatus::IntentToAdd => {}
},
Item::DirectoryContents { entry, .. } => match entry.status {
gix::dir::entry::Status::Untracked => counts.untracked += 1,
gix::dir::entry::Status::Tracked
| gix::dir::entry::Status::Ignored(_)
| gix::dir::entry::Status::Pruned => {}
},
Item::Rewrite { .. } => counts.modified += 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{git, head_sha};
fn head_shape_at(path: &Path) -> Result<Head, ProbeError> {
let repo = open_thread_safe(path)?;
head_shape(&repo.to_thread_local())
}
#[test]
fn a_freshly_initialised_repository_is_unborn() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
let head = head_shape_at(dir.path()).expect("read HEAD");
assert!(matches!(head, Head::Unborn(_)));
}
#[test]
fn a_commit_on_a_branch_reads_as_attached() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
let head = head_shape_at(dir.path()).expect("read HEAD");
match head {
Head::Branch { name, .. } => assert!(!name.is_empty()),
other => panic!("expected an attached branch, got {other:?}"),
}
}
#[test]
fn an_attached_branch_carries_its_own_resolved_commit() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
let sha = crate::test_support::head_sha(dir.path());
let head = head_shape_at(dir.path()).expect("read HEAD");
match head {
Head::Branch { commit, .. } => assert_eq!(commit.to_string(), sha),
other => panic!("expected an attached branch, got {other:?}"),
}
}
#[test]
fn a_detached_checkout_carries_the_commit_and_no_name() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
git(dir.path(), &["checkout", "--detach", "HEAD"]);
let head = head_shape_at(dir.path()).expect("read HEAD");
assert!(matches!(head, Head::Detached(_)));
}
#[test]
fn a_directory_that_is_not_a_repo_is_an_error() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(matches!(
head_shape_at(dir.path()),
Err(ProbeError::Open(_))
));
}
#[test]
fn a_head_file_that_will_not_parse_is_a_failure_not_a_shape() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
std::fs::write(
dir.path().join(".git").join("HEAD"),
"not a ref or an object id\n",
)
.expect("corrupt HEAD");
let result = head_shape_at(dir.path());
assert!(
result.is_err(),
"a HEAD that will not parse must be an error, got {result:?}"
);
}
#[test]
fn every_derived_handle_carries_an_object_cache_and_a_plain_open_does_not() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let shared = open_thread_safe(dir.path()).expect("open");
assert!(
shared.to_thread_local().objects.has_object_cache(),
"the first handle derived from the shared repository must carry an object cache"
);
assert!(
shared.to_thread_local().objects.has_object_cache(),
"a second handle, standing in for a later generation's probe, must carry one too"
);
assert!(
!gix::open(dir.path())
.expect("plain open")
.objects
.has_object_cache(),
"gix still leaves the object cache off by default, which is what this change is"
);
}
#[test]
fn two_threads_each_derive_their_own_repository_from_one_shared_handle() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
let shared = Arc::new(open_thread_safe(dir.path()).expect("open thread-safe repo"));
let readers: Vec<_> = (0..4)
.map(|_| {
let shared = Arc::clone(&shared);
std::thread::spawn(move || head_shape(&shared.to_thread_local()))
})
.collect();
for reader in readers {
let head = reader
.join()
.expect("reader thread panicked")
.expect("read HEAD");
assert!(matches!(head, Head::Branch { .. }));
}
}
#[test]
fn a_repository_with_no_operation_in_progress_reads_none() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
let repo = open_thread_safe(dir.path()).expect("open repo");
assert_eq!(in_progress_operation(&repo.to_thread_local()), None);
}
#[test]
fn a_conflicted_merge_reads_as_an_in_progress_merge_operation() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
std::fs::write(dir.path().join("file.txt"), "base\n").expect("write file");
git(dir.path(), &["add", "file.txt"]);
git(dir.path(), &["commit", "-m", "base"]);
git(dir.path(), &["checkout", "-b", "feature"]);
std::fs::write(dir.path().join("file.txt"), "feature\n").expect("write file");
git(dir.path(), &["commit", "-am", "feature change"]);
git(dir.path(), &["checkout", "-"]);
std::fs::write(dir.path().join("file.txt"), "main\n").expect("write file");
git(dir.path(), &["commit", "-am", "main change"]);
let merge = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(["merge", "feature"])
.output()
.expect("run git merge");
assert!(
dir.path().join(".git/MERGE_HEAD").exists(),
"the merge left no MERGE_HEAD, so there is no in-progress operation to read. \
git exited {:?}\nstdout: {}\nstderr: {}",
merge.status.code(),
String::from_utf8_lossy(&merge.stdout),
String::from_utf8_lossy(&merge.stderr),
);
let repo = open_thread_safe(dir.path()).expect("open repo");
assert_eq!(
in_progress_operation(&repo.to_thread_local()),
Some(InProgressOperation::Merge)
);
}
#[test]
fn recent_commits_is_empty_on_an_unborn_head() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
let repo = open_thread_safe(dir.path()).expect("open repo");
assert_eq!(recent_commits(&repo.to_thread_local(), 5), Vec::new());
}
#[test]
fn recent_commits_reads_the_most_recent_first_with_its_message_summary() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(
dir.path(),
&["commit", "--allow-empty", "-m", "first commit"],
);
git(
dir.path(),
&["commit", "--allow-empty", "-m", "second commit"],
);
let repo = open_thread_safe(dir.path()).expect("open repo");
let commits = recent_commits(&repo.to_thread_local(), 5);
assert_eq!(commits.len(), 2);
assert_eq!(&*commits[0].summary, "second commit");
assert_eq!(&*commits[1].summary, "first commit");
assert_eq!(commits[0].short_id.len(), 7);
}
#[test]
fn recent_commits_is_capped_at_the_given_limit() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
for n in 0..5 {
git(
dir.path(),
&["commit", "--allow-empty", "-m", &format!("commit {n}")],
);
}
let repo = open_thread_safe(dir.path()).expect("open repo");
let commits = recent_commits(&repo.to_thread_local(), 2);
assert_eq!(commits.len(), 2);
}
#[test]
fn every_variant_clones() {
let open = ProbeError::Open(Arc::from("boom"));
let read = ProbeError::Read(Arc::from("boom"));
let submodules = ProbeError::Submodules(Arc::from("boom"));
let ancestry = ProbeError::Ancestry(Arc::from("boom"));
assert_eq!(open.clone().to_string(), open.to_string());
assert_eq!(read.clone().to_string(), read.to_string());
assert_eq!(submodules.clone().to_string(), submodules.to_string());
assert_eq!(ancestry.clone().to_string(), ancestry.to_string());
}
fn init_repo_with_a_commit(path: &Path) {
std::fs::create_dir_all(path).expect("create repo dir");
gix::init(path).expect("init repo");
git(path, &["commit", "--allow-empty", "-m", "first"]);
}
#[test]
fn an_ordinary_repository_resolves_as_a_repo_whose_common_dir_is_its_own_git_dir() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo_with_a_commit(&root);
let resolved = resolve_boundary(&root).expect("resolve boundary");
assert!(matches!(resolved.kind, Kind::Repo));
assert_eq!(resolved.common_dir.as_ref(), root.join(".git"));
}
#[test]
fn a_linked_worktree_resolves_as_a_worktree_sharing_its_parents_common_dir() {
let dir = tempfile::tempdir().expect("temp dir");
let parent = dir.path().join("parent");
init_repo_with_a_commit(&parent);
let worktree = dir.path().join("worktree");
git(
&parent,
&[
"worktree",
"add",
"-b",
"feature",
worktree.to_str().expect("utf8 path"),
],
);
let parent_resolved = resolve_boundary(&parent).expect("resolve parent");
let worktree_resolved = resolve_boundary(&worktree).expect("resolve worktree");
assert!(matches!(worktree_resolved.kind, Kind::Worktree));
assert!(matches!(parent_resolved.kind, Kind::Repo));
assert_eq!(worktree_resolved.common_dir, parent_resolved.common_dir);
}
#[test]
fn linked_worktree_paths_names_the_worktree_linked_worktrees_counts() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let parent = root.join("parent");
init_repo_with_a_commit(&parent);
let worktree = root.join("worktree");
git(
&parent,
&[
"worktree",
"add",
"-b",
"feature",
worktree.to_str().expect("utf8 path"),
],
);
let repo = open_thread_safe(&parent).expect("open parent");
let repo = repo.to_thread_local();
assert_eq!(linked_worktrees(&repo).expect("count"), 1);
assert_eq!(linked_worktree_paths(&repo).expect("paths"), vec![worktree]);
}
#[test]
fn worktree_admin_dir_is_the_worktrees_own_git_dir_not_the_shared_common_dir() {
let dir = tempfile::tempdir().expect("temp dir");
let parent = dir.path().join("parent");
init_repo_with_a_commit(&parent);
let worktree = dir.path().join("worktree");
git(
&parent,
&[
"worktree",
"add",
"-b",
"feature",
worktree.to_str().expect("utf8 path"),
],
);
let repo = open_thread_safe(&worktree).expect("open worktree");
let repo = repo.to_thread_local();
let admin_dir = worktree_admin_dir(&repo)
.canonicalize()
.expect("canonicalize admin dir");
let common_dir = repo
.common_dir()
.canonicalize()
.expect("canonicalize common dir");
assert_ne!(admin_dir, common_dir);
assert!(
admin_dir.starts_with(common_dir.join("worktrees")),
"expected {admin_dir:?} under {:?}",
common_dir.join("worktrees")
);
}
#[test]
fn a_repo_with_no_gitmodules_resolves_to_no_submodules() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
assert_eq!(resolved.submodules.expect("no read failure"), Vec::new());
}
fn write_gitmodules(repo: &Path, entries: &[(&str, &str)]) {
let mut contents = String::new();
for (name, path) in entries {
contents.push_str(&format!(
"[submodule \"{name}\"]\n\tpath = {path}\n\turl = https://example.com/{name}.git\n"
));
}
std::fs::write(repo.join(".gitmodules"), contents).expect("write .gitmodules");
}
#[test]
fn a_gitmodules_entry_is_read_with_its_name_and_relative_path() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
write_gitmodules(dir.path(), &[("lib", "vendor/lib")]);
let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
let submodules = resolved.submodules.expect("no read failure");
assert_eq!(submodules.len(), 1);
assert_eq!(&*submodules[0].name, "lib");
assert_eq!(submodules[0].relative_path, Path::new("vendor/lib"));
}
#[test]
fn a_gitmodules_file_that_will_not_parse_is_reported_as_a_submodules_failure() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
std::fs::write(
dir.path().join(".gitmodules"),
"[submodule \"lib\"\n\tpath = lib\n",
)
.expect("write malformed .gitmodules");
let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
assert!(matches!(
resolved.submodules,
Err(ProbeError::Submodules(_))
));
}
#[test]
fn a_symlinked_gitmodules_file_is_treated_as_absent() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let real_file = dir.path().join("real-gitmodules");
std::fs::write(
&real_file,
"[submodule \"lib\"]\n\tpath = lib\n\turl = https://example.com/lib.git\n",
)
.expect("write real gitmodules contents");
std::os::unix::fs::symlink(&real_file, dir.path().join(".gitmodules"))
.expect("create symlink");
let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
assert_eq!(resolved.submodules.expect("no read failure"), Vec::new());
}
fn configure_upstream(path: &Path, branch_name: &str, upstream_sha: &str) {
let repo = open_thread_safe(path).expect("open repo").to_thread_local();
if !has_any_remote(&repo) {
git(
path,
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
);
}
git(
path,
&["config", &format!("branch.{branch_name}.remote"), "origin"],
);
git(
path,
&[
"config",
&format!("branch.{branch_name}.merge"),
&format!("refs/heads/{branch_name}"),
],
);
git(
path,
&[
"update-ref",
&format!("refs/remotes/origin/{branch_name}"),
upstream_sha,
],
);
}
#[test]
fn has_any_remote_is_false_until_one_is_added() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
assert!(!has_any_remote(&repo));
git(
dir.path(),
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
);
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
assert!(has_any_remote(&repo));
}
#[test]
fn ahead_behind_counts_commits_unique_to_each_side_not_the_total_on_either() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let fork_sha = head_sha(dir.path());
git(dir.path(), &["checkout", "-b", "feature"]);
std::fs::write(dir.path().join("feature.txt"), "one\n").expect("write file");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "feature work"]);
let feature_sha = head_sha(dir.path());
git(dir.path(), &["checkout", "main"]);
for name in ["a", "b"] {
std::fs::write(dir.path().join(format!("{name}.txt")), "content\n")
.expect("write file");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", &format!("main work {name}")]);
}
let main_sha = head_sha(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let fork = gix::ObjectId::from_hex(fork_sha.as_bytes()).expect("parse sha");
let feature = gix::ObjectId::from_hex(feature_sha.as_bytes()).expect("parse sha");
let main = gix::ObjectId::from_hex(main_sha.as_bytes()).expect("parse sha");
let against_fork = ahead_behind(&repo, main, fork).expect("ahead/behind against fork");
assert_eq!(
against_fork,
AheadBehind {
ahead: 2,
behind: 0
}
);
let against_feature =
ahead_behind(&repo, main, feature).expect("ahead/behind against feature");
assert_eq!(
against_feature,
AheadBehind {
ahead: 2,
behind: 1
},
"main's own two commits are ahead, feature's own one commit is behind"
);
}
#[test]
fn ahead_behind_of_a_branch_against_itself_is_zero_and_zero() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let sha = head_sha(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let commit = gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha");
let counts = ahead_behind(&repo, commit, commit).expect("ahead/behind reflexive");
assert_eq!(
counts,
AheadBehind {
ahead: 0,
behind: 0
}
);
}
#[test]
fn resolve_sync_settles_no_remote_even_though_the_branch_has_a_configured_upstream() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let sha = head_sha(dir.path());
git(dir.path(), &["config", "branch.main.remote", "origin"]);
git(
dir.path(),
&["config", "branch.main.merge", "refs/heads/main"],
);
git(
dir.path(),
&["update-ref", "refs/remotes/origin/main", &sha],
);
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let head = Head::Branch {
name: Arc::from("main"),
commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
};
let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
assert_eq!(sync, SyncState::NoRemote);
}
#[test]
fn resolve_sync_settles_no_upstream_for_a_branch_with_no_tracking_configured() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
git(
dir.path(),
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
);
let sha = head_sha(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let head = Head::Branch {
name: Arc::from("main"),
commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
};
let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
assert_eq!(sync, SyncState::NoUpstream);
}
#[test]
fn resolve_sync_settles_no_upstream_when_head_carries_no_branch() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
git(
dir.path(),
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
);
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let sync = resolve_sync(&repo, None).expect("resolve sync");
assert_eq!(sync, SyncState::NoUpstream);
}
#[test]
fn resolve_sync_computes_tracking_counts_against_a_live_upstream() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let upstream_sha = head_sha(dir.path());
configure_upstream(dir.path(), "main", &upstream_sha);
git(dir.path(), &["commit", "--allow-empty", "-m", "local work"]);
let tip_sha = head_sha(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let head = Head::Branch {
name: Arc::from("main"),
commit: gix::ObjectId::from_hex(tip_sha.as_bytes()).expect("parse sha"),
};
let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
assert_eq!(
sync,
SyncState::Tracking(AheadBehind {
ahead: 1,
behind: 0
})
);
}
#[test]
fn dirty_counts_reports_distinct_typed_counts_for_modified_untracked_and_deleted_paths() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init repo");
std::fs::write(dir.path().join("tracked-modified.txt"), "original\n")
.expect("write tracked file");
std::fs::write(dir.path().join("tracked-deleted-1.txt"), "bye\n")
.expect("write tracked file");
std::fs::write(dir.path().join("tracked-deleted-2.txt"), "bye\n")
.expect("write tracked file");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "first"]);
std::fs::write(dir.path().join("tracked-modified.txt"), "changed\n")
.expect("modify tracked file");
std::fs::remove_file(dir.path().join("tracked-deleted-1.txt"))
.expect("delete tracked file");
std::fs::remove_file(dir.path().join("tracked-deleted-2.txt"))
.expect("delete tracked file");
for name in ["new-1.txt", "new-2.txt", "new-3.txt"] {
std::fs::write(dir.path().join(name), "x").expect("write untracked file");
}
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let counts =
dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("compute dirty counts");
assert_eq!(
counts,
DirtyCounts {
modified: 1,
untracked: 3,
deleted: 2,
}
);
}
#[test]
fn dirty_counts_reports_a_clean_working_tree_as_all_zero() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let counts =
dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("compute dirty counts");
assert_eq!(counts, DirtyCounts::default());
}
#[test]
fn should_interrupt_owned_holds_its_own_clone_of_the_cancel_flag() {
let dir = tempfile::tempdir().expect("temp dir");
init_repo_with_a_commit(dir.path());
let repo = open_thread_safe(dir.path())
.expect("open")
.to_thread_local();
let cancel = Arc::new(AtomicBool::new(false));
let before = Arc::strong_count(&cancel);
let platform = repo
.status(gix::progress::Discard)
.expect("status platform")
.should_interrupt_owned(Arc::clone(&cancel));
assert_eq!(
Arc::strong_count(&cancel),
before + 1,
"should_interrupt_owned must hold its own clone of the cancel flag for the \
platform's lifetime, not merely borrow it"
);
drop(platform);
assert_eq!(
Arc::strong_count(&cancel),
before,
"dropping the platform must release its clone rather than leaking it"
);
}
fn repository_with_one_commit() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
dir
}
fn opened(path: &Path) -> gix::Repository {
open_thread_safe(path).expect("open").to_thread_local()
}
#[test]
fn a_repository_with_no_remote_ref_at_all_has_every_commit_unpushed() {
let dir = repository_with_one_commit();
let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
assert_eq!((commits, branches), (1, 1));
}
#[test]
fn a_commit_a_remote_tracking_ref_already_carries_is_not_unpushed() {
let dir = repository_with_one_commit();
let sha = head_sha(dir.path());
git(
dir.path(),
&["update-ref", "refs/remotes/origin/main", &sha],
);
let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
assert_eq!((commits, branches), (0, 0));
}
#[test]
fn unpushed_counts_commits_once_and_names_every_branch_carrying_one() {
let dir = repository_with_one_commit();
let sha = head_sha(dir.path());
git(
dir.path(),
&["update-ref", "refs/remotes/origin/main", &sha],
);
git(dir.path(), &["commit", "--allow-empty", "-m", "second"]);
git(dir.path(), &["branch", "sidecar"]);
git(dir.path(), &["checkout", "sidecar"]);
git(dir.path(), &["commit", "--allow-empty", "-m", "third"]);
let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
assert_eq!(
(commits, branches),
(2, 2),
"the commit both branches carry counts once, not once per branch, and both \
branches carrying one are named"
);
}
#[test]
fn an_unborn_repository_has_nothing_unpushed() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init(dir.path()).expect("init");
let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
assert_eq!((commits, branches), (0, 0));
}
#[test]
fn ignored_directories_for_deletion_collapses_an_ignored_tree_to_its_own_root() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo_with_a_commit(&root);
std::fs::write(root.join(".gitignore"), "node_modules/\n").expect("write .gitignore");
std::fs::create_dir_all(root.join("node_modules").join("a-package"))
.expect("create node_modules");
std::fs::write(
root.join("node_modules").join("a-package").join("index.js"),
"module.exports = {};\n",
)
.expect("write nested file");
git(&root, &["add", ".gitignore"]);
git(&root, &["commit", "-m", "ignore node_modules"]);
let ignored = ignored_directories_for_deletion(&opened(&root)).expect("enumerate ignored");
assert_eq!(ignored, vec![root.join("node_modules")]);
}
#[test]
fn ignored_directories_for_deletion_is_empty_with_no_gitignore() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo_with_a_commit(&root);
std::fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file");
git(&root, &["add", "tracked.txt"]);
git(&root, &["commit", "-m", "add tracked file"]);
std::fs::write(root.join("untracked.txt"), "untracked\n").expect("write untracked file");
let ignored = ignored_directories_for_deletion(&opened(&root)).expect("enumerate ignored");
assert_eq!(ignored, Vec::<PathBuf>::new());
}
#[test]
fn ignored_directories_for_deletion_on_a_bare_repository_is_empty() {
let dir = tempfile::tempdir().expect("temp dir");
gix::init_bare(dir.path()).expect("init bare");
let ignored =
ignored_directories_for_deletion(&opened(dir.path())).expect("enumerate ignored");
assert_eq!(ignored, Vec::<PathBuf>::new());
}
}