1use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::sync::atomic::AtomicBool;
14
15use crate::entity::{AheadBehind, DirtyCounts, Head, Kind, SyncState};
16
17#[derive(Clone, Debug)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub enum ProbeError {
23 Open(Arc<str>),
25 Read(Arc<str>),
27 Submodules(Arc<str>),
29 Ancestry(Arc<str>),
32 PatchEquivalence(Arc<str>),
35 AheadBehind(Arc<str>),
38 Base(Arc<str>),
41 Status(Arc<str>),
44 Unpushed(Arc<str>),
47 IgnoredDirectories(Arc<str>),
51}
52
53impl std::fmt::Display for ProbeError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 ProbeError::Open(message) => write!(f, "failed to open git repository: {message}"),
57 ProbeError::Read(message) => write!(f, "failed to read HEAD: {message}"),
58 ProbeError::Submodules(message) => write!(f, "failed to read .gitmodules: {message}"),
59 ProbeError::Ancestry(message) => write!(f, "failed to check ancestry: {message}"),
60 ProbeError::PatchEquivalence(message) => {
61 write!(f, "failed to check patch equivalence: {message}")
62 }
63 ProbeError::AheadBehind(message) => {
64 write!(f, "failed to compute ahead/behind counts: {message}")
65 }
66 ProbeError::Base(message) => {
67 write!(
68 f,
69 "failed to compute the behind-the-default-branch count: {message}"
70 )
71 }
72 ProbeError::Status(message) => write!(f, "failed to read status: {message}"),
73 ProbeError::Unpushed(message) => {
74 write!(f, "failed to count unpushed commits: {message}")
75 }
76 ProbeError::IgnoredDirectories(message) => {
77 write!(f, "failed to enumerate ignored directories: {message}")
78 }
79 }
80 }
81}
82
83impl std::error::Error for ProbeError {}
84
85pub(crate) fn checked_merge_base(
94 repo: &gix::Repository,
95 a: gix::ObjectId,
96 b: gix::ObjectId,
97) -> Result<Option<gix::ObjectId>, String> {
98 if a == b {
99 return Ok(Some(a));
100 }
101 for id in [a, b] {
102 if !repo.has_object(id) {
103 return Err(format!("commit object not found: {id}"));
104 }
105 }
106 match repo.merge_base(a, b) {
107 Ok(base) => Ok(Some(base.detach())),
108 Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
109 Err(other) => Err(other.to_string()),
110 }
111}
112
113pub(crate) fn has_any_remote(repo: &gix::Repository) -> bool {
120 !repo.remote_names().is_empty()
121}
122
123fn commits_unique_to(
127 repo: &gix::Repository,
128 tip: gix::ObjectId,
129 hidden: gix::ObjectId,
130) -> Result<u32, String> {
131 if tip == hidden {
132 return Ok(0);
133 }
134 for id in [tip, hidden] {
135 if !repo.has_object(id) {
136 return Err(format!("commit object not found: {id}"));
137 }
138 }
139 let walk = repo
140 .rev_walk([tip])
141 .with_hidden([hidden])
142 .all()
143 .map_err(|error| error.to_string())?;
144 let mut count = 0u32;
145 for info in walk {
146 info.map_err(|error| error.to_string())?;
147 count += 1;
148 }
149 Ok(count)
150}
151
152pub(crate) fn ahead_behind(
157 repo: &gix::Repository,
158 branch: gix::ObjectId,
159 upstream: gix::ObjectId,
160) -> Result<AheadBehind, String> {
161 Ok(AheadBehind {
162 ahead: commits_unique_to(repo, branch, upstream)?,
163 behind: commits_unique_to(repo, upstream, branch)?,
164 })
165}
166
167pub(crate) fn tracking_ref_name(
172 repo: &gix::Repository,
173 branch_name: &str,
174) -> Option<gix::refs::FullName> {
175 let full_name = gix::refs::FullName::try_from(format!("refs/heads/{branch_name}")).ok()?;
176 repo.branch_remote_tracking_ref_name(full_name.as_ref(), gix::remote::Direction::Fetch)?
177 .ok()
178}
179
180pub(crate) fn upstream_commit(repo: &gix::Repository, branch_name: &str) -> Option<gix::ObjectId> {
188 let tracking_ref_name = tracking_ref_name(repo, branch_name)?;
189 let mut reference = repo.find_reference(tracking_ref_name.as_ref()).ok()?;
190 reference.peel_to_id().ok().map(|id| id.detach())
191}
192
193pub(crate) fn commits_behind(
199 repo: &gix::Repository,
200 commit: gix::ObjectId,
201 default_commit: gix::ObjectId,
202) -> Result<u32, String> {
203 commits_unique_to(repo, default_commit, commit)
204}
205
206pub(crate) fn resolve_sync(
214 repo: &gix::Repository,
215 head: Option<&Head>,
216) -> Result<SyncState, ProbeError> {
217 if !has_any_remote(repo) {
218 return Ok(SyncState::NoRemote);
219 }
220 let Some(Head::Branch { name, commit }) = head else {
221 return Ok(SyncState::NoUpstream);
222 };
223 let Some(upstream) = upstream_commit(repo, name) else {
224 return Ok(SyncState::NoUpstream);
225 };
226 ahead_behind(repo, *commit, upstream)
227 .map(SyncState::Tracking)
228 .map_err(|error| ProbeError::AheadBehind(error.into()))
229}
230
231pub(crate) fn unpushed(repo: &gix::Repository) -> Result<(u32, u32), ProbeError> {
241 let remote_tips = branch_tips(repo, Branches::Remote)?;
242 let local_tips = branch_tips(repo, Branches::Local)?;
243 if local_tips.is_empty() {
244 return Ok((0, 0));
245 }
246
247 let mut branches = 0u32;
248 for tip in &local_tips {
249 if commits_not_carried_by(repo, &[*tip], &remote_tips)? > 0 {
250 branches += 1;
251 }
252 }
253 let commits = commits_not_carried_by(repo, &local_tips, &remote_tips)?;
254 Ok((commits, branches))
255}
256
257#[derive(Debug, Clone, Copy)]
259enum Branches {
260 Local,
261 Remote,
262}
263
264fn commits_not_carried_by(
268 repo: &gix::Repository,
269 tips: &[gix::ObjectId],
270 hidden: &[gix::ObjectId],
271) -> Result<u32, ProbeError> {
272 let walk = repo
273 .rev_walk(tips.iter().copied())
274 .with_hidden(hidden.iter().copied())
275 .all()
276 .map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
277 let mut count = 0u32;
278 for info in walk {
279 info.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
280 count += 1;
281 }
282 Ok(count)
283}
284
285fn branch_tips(repo: &gix::Repository, which: Branches) -> Result<Vec<gix::ObjectId>, ProbeError> {
290 let platform = repo
291 .references()
292 .map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
293 let iter = match which {
294 Branches::Local => platform.local_branches(),
295 Branches::Remote => platform.remote_branches(),
296 }
297 .map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
298
299 let mut tips = Vec::new();
300 for reference in iter {
301 let mut reference =
302 reference.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
303 if let Ok(id) = reference.peel_to_id() {
304 tips.push(id.detach());
305 }
306 }
307 Ok(tips)
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318#[cfg_attr(feature = "serde", derive(serde::Serialize))]
319pub enum InProgressOperation {
320 ApplyMailbox,
321 ApplyMailboxRebase,
322 Bisect,
323 CherryPick,
324 CherryPickSequence,
325 Merge,
326 Rebase,
327 RebaseInteractive,
328 Revert,
329 RevertSequence,
330}
331
332pub(crate) fn in_progress_operation(repo: &gix::Repository) -> Option<InProgressOperation> {
337 match repo.state()? {
338 gix::state::InProgress::ApplyMailbox => Some(InProgressOperation::ApplyMailbox),
339 gix::state::InProgress::ApplyMailboxRebase => Some(InProgressOperation::ApplyMailboxRebase),
340 gix::state::InProgress::Bisect => Some(InProgressOperation::Bisect),
341 gix::state::InProgress::CherryPick => Some(InProgressOperation::CherryPick),
342 gix::state::InProgress::CherryPickSequence => Some(InProgressOperation::CherryPickSequence),
343 gix::state::InProgress::Merge => Some(InProgressOperation::Merge),
344 gix::state::InProgress::Rebase => Some(InProgressOperation::Rebase),
345 gix::state::InProgress::RebaseInteractive => Some(InProgressOperation::RebaseInteractive),
346 gix::state::InProgress::Revert => Some(InProgressOperation::Revert),
347 gix::state::InProgress::RevertSequence => Some(InProgressOperation::RevertSequence),
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
356#[cfg_attr(feature = "serde", derive(serde::Serialize))]
357pub struct RecentCommit {
358 pub short_id: Arc<str>,
359 pub summary: Arc<str>,
360}
361
362pub(crate) fn recent_commits(repo: &gix::Repository, limit: usize) -> Vec<RecentCommit> {
368 let Ok(head_commit) = repo.head_commit() else {
369 return Vec::new();
370 };
371 let Ok(walk) = head_commit.id().ancestors().all() else {
372 return Vec::new();
373 };
374
375 let mut commits = Vec::new();
376 for info in walk.take(limit) {
377 let Ok(info) = info else { break };
378 let short_id = info.id.to_string().chars().take(7).collect::<String>();
379 let summary = repo
380 .find_object(info.id)
381 .ok()
382 .and_then(|object| object.try_into_commit().ok())
383 .and_then(|commit| {
384 commit
385 .message()
386 .ok()
387 .map(|message| message.summary().to_string())
388 })
389 .unwrap_or_default();
390 commits.push(RecentCommit {
391 short_id: Arc::from(short_id),
392 summary: Arc::from(summary),
393 });
394 }
395 commits
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub(crate) struct SubmoduleEntry {
401 pub name: Arc<str>,
402 pub relative_path: PathBuf,
403}
404
405pub(crate) struct Resolved {
414 pub kind: Kind,
415 pub common_dir: Arc<Path>,
416 pub submodules: Result<Vec<SubmoduleEntry>, ProbeError>,
417 pub repo: gix::ThreadSafeRepository,
418}
419
420pub(crate) fn resolve_from_open(repo: gix::Repository) -> Resolved {
432 let kind = match repo.kind() {
433 gix::repository::Kind::LinkedWorkTree => Kind::Worktree,
434 gix::repository::Kind::Common | gix::repository::Kind::Submodule => Kind::Repo,
435 };
436 let common_dir = repo.common_dir();
437 let common_dir: Arc<Path> =
438 Arc::from(std::fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf()));
439 let submodules = read_gitmodules(&repo).map(|entries| entries.unwrap_or_default());
440 Resolved {
441 kind,
442 common_dir,
443 submodules,
444 repo: repo.into_sync(),
445 }
446}
447
448pub(crate) fn resolve_boundary(path: &Path) -> Result<Resolved, ProbeError> {
451 let repo = gix::open(path).map_err(|error| ProbeError::Open(error.to_string().into()))?;
452 Ok(resolve_from_open(repo))
453}
454
455pub(crate) fn common_dir_of(path: &Path) -> Result<Arc<Path>, ProbeError> {
460 let repo = gix::open(path).map_err(|error| ProbeError::Open(error.to_string().into()))?;
461 let common_dir = repo.common_dir();
462 Ok(Arc::from(
463 std::fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf()),
464 ))
465}
466
467const OBJECT_CACHE_BYTES: usize = 4 * 1024 * 1024;
485
486pub(crate) fn open_thread_safe(path: &Path) -> Result<gix::ThreadSafeRepository, ProbeError> {
505 let options = gix::open::Options::default()
506 .config_overrides([format!("gitoxide.objects.cacheLimit={OBJECT_CACHE_BYTES}")]);
507 gix::ThreadSafeRepository::open_opts(path, options)
508 .map_err(|error| ProbeError::Open(error.to_string().into()))
509}
510
511fn read_gitmodules(repo: &gix::Repository) -> Result<Option<Vec<SubmoduleEntry>>, ProbeError> {
520 let Some(modules) = repo
521 .open_modules_file()
522 .map_err(|error| ProbeError::Submodules(error.to_string().into()))?
523 else {
524 return Ok(None);
525 };
526
527 let mut entries = Vec::new();
528 for name in modules.names() {
529 let relative_path = modules
530 .path(name)
531 .map_err(|error| ProbeError::Submodules(error.to_string().into()))?;
532 entries.push(SubmoduleEntry {
533 name: Arc::from(name.to_string()),
534 relative_path: gix::path::from_bstring(relative_path),
535 });
536 }
537 Ok(Some(entries))
538}
539
540pub fn head_shape(repo: &gix::Repository) -> Result<Head, ProbeError> {
550 let head = repo
551 .head()
552 .map_err(|error| ProbeError::Read(error.to_string().into()))?;
553 let commit = head.id().map(|id| id.detach());
554 Ok(match head.kind {
555 gix::head::Kind::Symbolic(reference) => {
556 let Some(commit) = commit else {
557 return Err(ProbeError::Read(
560 "attached HEAD resolved no commit".to_string().into(),
561 ));
562 };
563 Head::Branch {
564 name: Arc::from(reference.name.shorten().to_string()),
565 commit,
566 }
567 }
568 gix::head::Kind::Unborn(name) => Head::Unborn(Arc::from(name.shorten().to_string())),
569 gix::head::Kind::Detached { target, peeled } => Head::Detached(peeled.unwrap_or(target)),
570 })
571}
572
573pub(crate) fn dirty_counts(
590 repo: &gix::Repository,
591 cancel: Arc<AtomicBool>,
592) -> Result<DirtyCounts, ProbeError> {
593 let platform = repo
595 .status(gix::progress::Discard)
596 .map_err(|error| ProbeError::Status(error.to_string().into()))?
597 .index_worktree_options_mut(|options| options.thread_limit = Some(1))
598 .should_interrupt_owned(cancel);
599 let iter = platform
601 .into_index_worktree_iter(Vec::new())
602 .map_err(|error| ProbeError::Status(error.to_string().into()))?;
603
604 let mut counts = DirtyCounts::default();
605 for item in iter {
606 let item = item.map_err(|error| ProbeError::Status(error.to_string().into()))?;
607 classify_index_worktree_item(&item, &mut counts);
608 }
609 Ok(counts)
610}
611
612pub(crate) fn linked_worktrees(repo: &gix::Repository) -> Result<u32, ProbeError> {
619 repo.worktrees()
620 .map(|worktrees| worktrees.len() as u32)
621 .map_err(|error| ProbeError::Read(error.to_string().into()))
622}
623
624pub(crate) fn linked_worktree_paths(repo: &gix::Repository) -> Result<Vec<PathBuf>, ProbeError> {
635 Ok(repo
636 .worktrees()
637 .map_err(|error| ProbeError::Read(error.to_string().into()))?
638 .into_iter()
639 .filter_map(|worktree| worktree.base().ok())
640 .collect())
641}
642
643pub(crate) fn worktree_admin_dir(repo: &gix::Repository) -> PathBuf {
649 repo.git_dir().to_path_buf()
650}
651
652pub(crate) fn ignored_directories_for_deletion(
669 repo: &gix::Repository,
670) -> Result<Vec<PathBuf>, ProbeError> {
671 if repo.workdir().is_none() {
672 return Ok(Vec::new());
673 }
674 let index = repo
675 .index_or_load_from_head_or_empty()
676 .map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?;
677 let options = repo
678 .dirwalk_options()
679 .map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?
680 .emit_ignored(Some(gix::dir::walk::EmissionMode::CollapseDirectory))
681 .for_deletion(Some(
682 gix::dir::walk::ForDeletionMode::IgnoredDirectoriesCanHideNestedRepositories,
683 ));
684 let should_interrupt = AtomicBool::new(false);
685 let mut ignored = IgnoredEntries::default();
686 let outcome = repo
687 .dirwalk(
688 &index,
689 Vec::<&str>::new(),
690 &should_interrupt,
691 options,
692 &mut ignored,
693 )
694 .map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?;
695 Ok(ignored
696 .rela_paths
697 .into_iter()
698 .map(|rela_path| {
699 outcome
700 .traversal_root
701 .join(gix::path::from_bstring(rela_path))
702 })
703 .collect())
704}
705
706#[derive(Default)]
710struct IgnoredEntries {
711 rela_paths: Vec<gix::bstr::BString>,
712}
713
714impl gix::dir::walk::Delegate for IgnoredEntries {
715 fn emit(
716 &mut self,
717 entry: gix::dir::EntryRef<'_>,
718 _collapsed_directory_status: Option<gix::dir::entry::Status>,
719 ) -> gix::dir::walk::Action {
720 if matches!(entry.status, gix::dir::entry::Status::Ignored(_)) {
721 self.rela_paths.push(entry.rela_path.into_owned());
722 }
723 std::ops::ControlFlow::Continue(())
724 }
725}
726
727pub(crate) fn staged_changes(repo: &gix::Repository) -> Result<bool, ProbeError> {
737 let head_tree = repo
738 .head_tree_id_or_empty()
739 .map_err(|error| ProbeError::Status(error.to_string().into()))?;
740 let index = repo
741 .index_or_empty()
742 .map_err(|error| ProbeError::Status(error.to_string().into()))?;
743 let mut staged = false;
744 repo.tree_index_status(
745 &head_tree,
746 &index,
747 None,
748 gix::status::tree_index::TrackRenames::Disabled,
749 |_, _, _| {
750 staged = true;
751 Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(()))
752 },
753 )
754 .map_err(|error| ProbeError::Status(error.to_string().into()))?;
755 Ok(staged)
756}
757
758fn classify_index_worktree_item(
764 item: &gix::status::index_worktree::Item,
765 counts: &mut DirtyCounts,
766) {
767 use gix::status::index_worktree::Item;
768 use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
769
770 match item {
771 Item::Modification { status, .. } => match status {
772 EntryStatus::Conflict { .. } => counts.modified += 1,
773 EntryStatus::Change(change) => match change {
774 Change::Removed => counts.deleted += 1,
775 Change::Type { .. } => counts.modified += 1,
776 Change::Modification { .. } => counts.modified += 1,
777 Change::SubmoduleModification(_) => counts.modified += 1,
778 },
779 EntryStatus::NeedsUpdate(_) | EntryStatus::IntentToAdd => {}
782 },
783 Item::DirectoryContents { entry, .. } => match entry.status {
784 gix::dir::entry::Status::Untracked => counts.untracked += 1,
785 gix::dir::entry::Status::Tracked
789 | gix::dir::entry::Status::Ignored(_)
790 | gix::dir::entry::Status::Pruned => {}
791 },
792 Item::Rewrite { .. } => counts.modified += 1,
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::test_support::{git, head_sha};
804
805 fn head_shape_at(path: &Path) -> Result<Head, ProbeError> {
810 let repo = open_thread_safe(path)?;
811 head_shape(&repo.to_thread_local())
812 }
813
814 #[test]
815 fn a_freshly_initialised_repository_is_unborn() {
816 let dir = tempfile::tempdir().expect("temp dir");
817 gix::init(dir.path()).expect("init");
818
819 let head = head_shape_at(dir.path()).expect("read HEAD");
820
821 assert!(matches!(head, Head::Unborn(_)));
822 }
823
824 #[test]
825 fn a_commit_on_a_branch_reads_as_attached() {
826 let dir = tempfile::tempdir().expect("temp dir");
827 gix::init(dir.path()).expect("init");
828 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
829
830 let head = head_shape_at(dir.path()).expect("read HEAD");
831
832 match head {
833 Head::Branch { name, .. } => assert!(!name.is_empty()),
834 other => panic!("expected an attached branch, got {other:?}"),
835 }
836 }
837
838 #[test]
841 fn an_attached_branch_carries_its_own_resolved_commit() {
842 let dir = tempfile::tempdir().expect("temp dir");
843 gix::init(dir.path()).expect("init");
844 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
845 let sha = crate::test_support::head_sha(dir.path());
846
847 let head = head_shape_at(dir.path()).expect("read HEAD");
848
849 match head {
850 Head::Branch { commit, .. } => assert_eq!(commit.to_string(), sha),
851 other => panic!("expected an attached branch, got {other:?}"),
852 }
853 }
854
855 #[test]
856 fn a_detached_checkout_carries_the_commit_and_no_name() {
857 let dir = tempfile::tempdir().expect("temp dir");
858 gix::init(dir.path()).expect("init");
859 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
860 git(dir.path(), &["checkout", "--detach", "HEAD"]);
861
862 let head = head_shape_at(dir.path()).expect("read HEAD");
863
864 assert!(matches!(head, Head::Detached(_)));
865 }
866
867 #[test]
868 fn a_directory_that_is_not_a_repo_is_an_error() {
869 let dir = tempfile::tempdir().expect("temp dir");
870
871 assert!(matches!(
872 head_shape_at(dir.path()),
873 Err(ProbeError::Open(_))
874 ));
875 }
876
877 #[test]
881 fn a_head_file_that_will_not_parse_is_a_failure_not_a_shape() {
882 let dir = tempfile::tempdir().expect("temp dir");
883 gix::init(dir.path()).expect("init");
884 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
885 std::fs::write(
886 dir.path().join(".git").join("HEAD"),
887 "not a ref or an object id\n",
888 )
889 .expect("corrupt HEAD");
890
891 let result = head_shape_at(dir.path());
892
893 assert!(
894 result.is_err(),
895 "a HEAD that will not parse must be an error, got {result:?}"
896 );
897 }
898
899 #[test]
911 fn every_derived_handle_carries_an_object_cache_and_a_plain_open_does_not() {
912 let dir = tempfile::tempdir().expect("temp dir");
913 init_repo_with_a_commit(dir.path());
914
915 let shared = open_thread_safe(dir.path()).expect("open");
916 assert!(
917 shared.to_thread_local().objects.has_object_cache(),
918 "the first handle derived from the shared repository must carry an object cache"
919 );
920 assert!(
921 shared.to_thread_local().objects.has_object_cache(),
922 "a second handle, standing in for a later generation's probe, must carry one too"
923 );
924
925 assert!(
926 !gix::open(dir.path())
927 .expect("plain open")
928 .objects
929 .has_object_cache(),
930 "gix still leaves the object cache off by default, which is what this change is"
931 );
932 }
933
934 #[test]
935 fn two_threads_each_derive_their_own_repository_from_one_shared_handle() {
936 let dir = tempfile::tempdir().expect("temp dir");
937 gix::init(dir.path()).expect("init");
938 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
939
940 let shared = Arc::new(open_thread_safe(dir.path()).expect("open thread-safe repo"));
941
942 let readers: Vec<_> = (0..4)
943 .map(|_| {
944 let shared = Arc::clone(&shared);
945 std::thread::spawn(move || head_shape(&shared.to_thread_local()))
946 })
947 .collect();
948
949 for reader in readers {
950 let head = reader
951 .join()
952 .expect("reader thread panicked")
953 .expect("read HEAD");
954 assert!(matches!(head, Head::Branch { .. }));
955 }
956 }
957
958 #[test]
959 fn a_repository_with_no_operation_in_progress_reads_none() {
960 let dir = tempfile::tempdir().expect("temp dir");
961 gix::init(dir.path()).expect("init");
962 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
963
964 let repo = open_thread_safe(dir.path()).expect("open repo");
965 assert_eq!(in_progress_operation(&repo.to_thread_local()), None);
966 }
967
968 #[test]
972 fn a_conflicted_merge_reads_as_an_in_progress_merge_operation() {
973 let dir = tempfile::tempdir().expect("temp dir");
974 gix::init(dir.path()).expect("init");
975 std::fs::write(dir.path().join("file.txt"), "base\n").expect("write file");
976 git(dir.path(), &["add", "file.txt"]);
977 git(dir.path(), &["commit", "-m", "base"]);
978 git(dir.path(), &["checkout", "-b", "feature"]);
979 std::fs::write(dir.path().join("file.txt"), "feature\n").expect("write file");
980 git(dir.path(), &["commit", "-am", "feature change"]);
981 git(dir.path(), &["checkout", "-"]);
982 std::fs::write(dir.path().join("file.txt"), "main\n").expect("write file");
983 git(dir.path(), &["commit", "-am", "main change"]);
984 let merge = std::process::Command::new("git")
989 .arg("-C")
990 .arg(dir.path())
991 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
992 .args(["merge", "feature"])
993 .output()
994 .expect("run git merge");
995
996 assert!(
1000 dir.path().join(".git/MERGE_HEAD").exists(),
1001 "the merge left no MERGE_HEAD, so there is no in-progress operation to read. \
1002 git exited {:?}\nstdout: {}\nstderr: {}",
1003 merge.status.code(),
1004 String::from_utf8_lossy(&merge.stdout),
1005 String::from_utf8_lossy(&merge.stderr),
1006 );
1007
1008 let repo = open_thread_safe(dir.path()).expect("open repo");
1009
1010 assert_eq!(
1011 in_progress_operation(&repo.to_thread_local()),
1012 Some(InProgressOperation::Merge)
1013 );
1014 }
1015
1016 #[test]
1017 fn recent_commits_is_empty_on_an_unborn_head() {
1018 let dir = tempfile::tempdir().expect("temp dir");
1019 gix::init(dir.path()).expect("init");
1020
1021 let repo = open_thread_safe(dir.path()).expect("open repo");
1022
1023 assert_eq!(recent_commits(&repo.to_thread_local(), 5), Vec::new());
1024 }
1025
1026 #[test]
1027 fn recent_commits_reads_the_most_recent_first_with_its_message_summary() {
1028 let dir = tempfile::tempdir().expect("temp dir");
1029 gix::init(dir.path()).expect("init");
1030 git(
1031 dir.path(),
1032 &["commit", "--allow-empty", "-m", "first commit"],
1033 );
1034 git(
1035 dir.path(),
1036 &["commit", "--allow-empty", "-m", "second commit"],
1037 );
1038
1039 let repo = open_thread_safe(dir.path()).expect("open repo");
1040 let commits = recent_commits(&repo.to_thread_local(), 5);
1041
1042 assert_eq!(commits.len(), 2);
1043 assert_eq!(&*commits[0].summary, "second commit");
1044 assert_eq!(&*commits[1].summary, "first commit");
1045 assert_eq!(commits[0].short_id.len(), 7);
1046 }
1047
1048 #[test]
1049 fn recent_commits_is_capped_at_the_given_limit() {
1050 let dir = tempfile::tempdir().expect("temp dir");
1051 gix::init(dir.path()).expect("init");
1052 for n in 0..5 {
1053 git(
1054 dir.path(),
1055 &["commit", "--allow-empty", "-m", &format!("commit {n}")],
1056 );
1057 }
1058
1059 let repo = open_thread_safe(dir.path()).expect("open repo");
1060 let commits = recent_commits(&repo.to_thread_local(), 2);
1061
1062 assert_eq!(commits.len(), 2);
1063 }
1064
1065 #[test]
1066 fn every_variant_clones() {
1067 let open = ProbeError::Open(Arc::from("boom"));
1068 let read = ProbeError::Read(Arc::from("boom"));
1069 let submodules = ProbeError::Submodules(Arc::from("boom"));
1070 let ancestry = ProbeError::Ancestry(Arc::from("boom"));
1071
1072 assert_eq!(open.clone().to_string(), open.to_string());
1073 assert_eq!(read.clone().to_string(), read.to_string());
1074 assert_eq!(submodules.clone().to_string(), submodules.to_string());
1075 assert_eq!(ancestry.clone().to_string(), ancestry.to_string());
1076 }
1077
1078 fn init_repo_with_a_commit(path: &Path) {
1079 std::fs::create_dir_all(path).expect("create repo dir");
1080 gix::init(path).expect("init repo");
1081 git(path, &["commit", "--allow-empty", "-m", "first"]);
1082 }
1083
1084 #[test]
1085 fn an_ordinary_repository_resolves_as_a_repo_whose_common_dir_is_its_own_git_dir() {
1086 let dir = tempfile::tempdir().expect("temp dir");
1087 let root = dir.path().canonicalize().expect("canonicalize temp dir");
1088 init_repo_with_a_commit(&root);
1089
1090 let resolved = resolve_boundary(&root).expect("resolve boundary");
1091
1092 assert!(matches!(resolved.kind, Kind::Repo));
1093 assert_eq!(resolved.common_dir.as_ref(), root.join(".git"));
1094 }
1095
1096 #[test]
1101 fn a_linked_worktree_resolves_as_a_worktree_sharing_its_parents_common_dir() {
1102 let dir = tempfile::tempdir().expect("temp dir");
1103 let parent = dir.path().join("parent");
1104 init_repo_with_a_commit(&parent);
1105 let worktree = dir.path().join("worktree");
1106 git(
1107 &parent,
1108 &[
1109 "worktree",
1110 "add",
1111 "-b",
1112 "feature",
1113 worktree.to_str().expect("utf8 path"),
1114 ],
1115 );
1116
1117 let parent_resolved = resolve_boundary(&parent).expect("resolve parent");
1118 let worktree_resolved = resolve_boundary(&worktree).expect("resolve worktree");
1119
1120 assert!(matches!(worktree_resolved.kind, Kind::Worktree));
1121 assert!(matches!(parent_resolved.kind, Kind::Repo));
1122 assert_eq!(worktree_resolved.common_dir, parent_resolved.common_dir);
1123 }
1124
1125 #[test]
1129 fn linked_worktree_paths_names_the_worktree_linked_worktrees_counts() {
1130 let dir = tempfile::tempdir().expect("temp dir");
1131 let root = dir.path().canonicalize().expect("canonicalize temp dir");
1132 let parent = root.join("parent");
1133 init_repo_with_a_commit(&parent);
1134 let worktree = root.join("worktree");
1135 git(
1136 &parent,
1137 &[
1138 "worktree",
1139 "add",
1140 "-b",
1141 "feature",
1142 worktree.to_str().expect("utf8 path"),
1143 ],
1144 );
1145
1146 let repo = open_thread_safe(&parent).expect("open parent");
1147 let repo = repo.to_thread_local();
1148
1149 assert_eq!(linked_worktrees(&repo).expect("count"), 1);
1150 assert_eq!(linked_worktree_paths(&repo).expect("paths"), vec![worktree]);
1151 }
1152
1153 #[test]
1157 fn worktree_admin_dir_is_the_worktrees_own_git_dir_not_the_shared_common_dir() {
1158 let dir = tempfile::tempdir().expect("temp dir");
1159 let parent = dir.path().join("parent");
1160 init_repo_with_a_commit(&parent);
1161 let worktree = dir.path().join("worktree");
1162 git(
1163 &parent,
1164 &[
1165 "worktree",
1166 "add",
1167 "-b",
1168 "feature",
1169 worktree.to_str().expect("utf8 path"),
1170 ],
1171 );
1172
1173 let repo = open_thread_safe(&worktree).expect("open worktree");
1174 let repo = repo.to_thread_local();
1175
1176 let admin_dir = worktree_admin_dir(&repo)
1177 .canonicalize()
1178 .expect("canonicalize admin dir");
1179 let common_dir = repo
1180 .common_dir()
1181 .canonicalize()
1182 .expect("canonicalize common dir");
1183 assert_ne!(admin_dir, common_dir);
1184 assert!(
1185 admin_dir.starts_with(common_dir.join("worktrees")),
1186 "expected {admin_dir:?} under {:?}",
1187 common_dir.join("worktrees")
1188 );
1189 }
1190
1191 #[test]
1192 fn a_repo_with_no_gitmodules_resolves_to_no_submodules() {
1193 let dir = tempfile::tempdir().expect("temp dir");
1194 init_repo_with_a_commit(dir.path());
1195
1196 let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1197
1198 assert_eq!(resolved.submodules.expect("no read failure"), Vec::new());
1199 }
1200
1201 fn write_gitmodules(repo: &Path, entries: &[(&str, &str)]) {
1205 let mut contents = String::new();
1206 for (name, path) in entries {
1207 contents.push_str(&format!(
1208 "[submodule \"{name}\"]\n\tpath = {path}\n\turl = https://example.com/{name}.git\n"
1209 ));
1210 }
1211 std::fs::write(repo.join(".gitmodules"), contents).expect("write .gitmodules");
1212 }
1213
1214 #[test]
1215 fn a_gitmodules_entry_is_read_with_its_name_and_relative_path() {
1216 let dir = tempfile::tempdir().expect("temp dir");
1217 init_repo_with_a_commit(dir.path());
1218 write_gitmodules(dir.path(), &[("lib", "vendor/lib")]);
1219
1220 let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1221 let submodules = resolved.submodules.expect("no read failure");
1222
1223 assert_eq!(submodules.len(), 1);
1224 assert_eq!(&*submodules[0].name, "lib");
1225 assert_eq!(submodules[0].relative_path, Path::new("vendor/lib"));
1226 }
1227
1228 #[test]
1229 fn a_gitmodules_file_that_will_not_parse_is_reported_as_a_submodules_failure() {
1230 let dir = tempfile::tempdir().expect("temp dir");
1231 init_repo_with_a_commit(dir.path());
1232 std::fs::write(
1234 dir.path().join(".gitmodules"),
1235 "[submodule \"lib\"\n\tpath = lib\n",
1236 )
1237 .expect("write malformed .gitmodules");
1238
1239 let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1240
1241 assert!(matches!(
1242 resolved.submodules,
1243 Err(ProbeError::Submodules(_))
1244 ));
1245 }
1246
1247 #[test]
1250 fn a_symlinked_gitmodules_file_is_treated_as_absent() {
1251 let dir = tempfile::tempdir().expect("temp dir");
1252 init_repo_with_a_commit(dir.path());
1253 let real_file = dir.path().join("real-gitmodules");
1254 std::fs::write(
1255 &real_file,
1256 "[submodule \"lib\"]\n\tpath = lib\n\turl = https://example.com/lib.git\n",
1257 )
1258 .expect("write real gitmodules contents");
1259 std::os::unix::fs::symlink(&real_file, dir.path().join(".gitmodules"))
1260 .expect("create symlink");
1261
1262 let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1263
1264 assert_eq!(resolved.submodules.expect("no read failure"), Vec::new());
1265 }
1266
1267 fn configure_upstream(path: &Path, branch_name: &str, upstream_sha: &str) {
1274 let repo = open_thread_safe(path).expect("open repo").to_thread_local();
1275 if !has_any_remote(&repo) {
1276 git(
1277 path,
1278 &[
1279 "remote",
1280 "add",
1281 "origin",
1282 "https://example.invalid/repo.git",
1283 ],
1284 );
1285 }
1286 git(
1287 path,
1288 &["config", &format!("branch.{branch_name}.remote"), "origin"],
1289 );
1290 git(
1291 path,
1292 &[
1293 "config",
1294 &format!("branch.{branch_name}.merge"),
1295 &format!("refs/heads/{branch_name}"),
1296 ],
1297 );
1298 git(
1299 path,
1300 &[
1301 "update-ref",
1302 &format!("refs/remotes/origin/{branch_name}"),
1303 upstream_sha,
1304 ],
1305 );
1306 }
1307
1308 #[test]
1309 fn has_any_remote_is_false_until_one_is_added() {
1310 let dir = tempfile::tempdir().expect("temp dir");
1311 init_repo_with_a_commit(dir.path());
1312 let repo = open_thread_safe(dir.path())
1313 .expect("open")
1314 .to_thread_local();
1315 assert!(!has_any_remote(&repo));
1316
1317 git(
1318 dir.path(),
1319 &[
1320 "remote",
1321 "add",
1322 "origin",
1323 "https://example.invalid/repo.git",
1324 ],
1325 );
1326 let repo = open_thread_safe(dir.path())
1327 .expect("open")
1328 .to_thread_local();
1329 assert!(has_any_remote(&repo));
1330 }
1331
1332 #[test]
1339 fn ahead_behind_counts_commits_unique_to_each_side_not_the_total_on_either() {
1340 let dir = tempfile::tempdir().expect("temp dir");
1341 init_repo_with_a_commit(dir.path());
1342 let fork_sha = head_sha(dir.path());
1343 git(dir.path(), &["checkout", "-b", "feature"]);
1344 std::fs::write(dir.path().join("feature.txt"), "one\n").expect("write file");
1345 git(dir.path(), &["add", "."]);
1346 git(dir.path(), &["commit", "-m", "feature work"]);
1347 let feature_sha = head_sha(dir.path());
1348 git(dir.path(), &["checkout", "main"]);
1349 for name in ["a", "b"] {
1350 std::fs::write(dir.path().join(format!("{name}.txt")), "content\n")
1351 .expect("write file");
1352 git(dir.path(), &["add", "."]);
1353 git(dir.path(), &["commit", "-m", &format!("main work {name}")]);
1354 }
1355 let main_sha = head_sha(dir.path());
1356 let repo = open_thread_safe(dir.path())
1357 .expect("open")
1358 .to_thread_local();
1359 let fork = gix::ObjectId::from_hex(fork_sha.as_bytes()).expect("parse sha");
1360 let feature = gix::ObjectId::from_hex(feature_sha.as_bytes()).expect("parse sha");
1361 let main = gix::ObjectId::from_hex(main_sha.as_bytes()).expect("parse sha");
1362
1363 let against_fork = ahead_behind(&repo, main, fork).expect("ahead/behind against fork");
1364 assert_eq!(
1365 against_fork,
1366 AheadBehind {
1367 ahead: 2,
1368 behind: 0
1369 }
1370 );
1371
1372 let against_feature =
1373 ahead_behind(&repo, main, feature).expect("ahead/behind against feature");
1374 assert_eq!(
1375 against_feature,
1376 AheadBehind {
1377 ahead: 2,
1378 behind: 1
1379 },
1380 "main's own two commits are ahead, feature's own one commit is behind"
1381 );
1382 }
1383
1384 #[test]
1385 fn ahead_behind_of_a_branch_against_itself_is_zero_and_zero() {
1386 let dir = tempfile::tempdir().expect("temp dir");
1387 init_repo_with_a_commit(dir.path());
1388 let sha = head_sha(dir.path());
1389 let repo = open_thread_safe(dir.path())
1390 .expect("open")
1391 .to_thread_local();
1392 let commit = gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha");
1393
1394 let counts = ahead_behind(&repo, commit, commit).expect("ahead/behind reflexive");
1395
1396 assert_eq!(
1397 counts,
1398 AheadBehind {
1399 ahead: 0,
1400 behind: 0
1401 }
1402 );
1403 }
1404
1405 #[test]
1406 fn resolve_sync_settles_no_remote_even_though_the_branch_has_a_configured_upstream() {
1407 let dir = tempfile::tempdir().expect("temp dir");
1408 init_repo_with_a_commit(dir.path());
1409 let sha = head_sha(dir.path());
1410 git(dir.path(), &["config", "branch.main.remote", "origin"]);
1414 git(
1415 dir.path(),
1416 &["config", "branch.main.merge", "refs/heads/main"],
1417 );
1418 git(
1419 dir.path(),
1420 &["update-ref", "refs/remotes/origin/main", &sha],
1421 );
1422 let repo = open_thread_safe(dir.path())
1423 .expect("open")
1424 .to_thread_local();
1425 let head = Head::Branch {
1426 name: Arc::from("main"),
1427 commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
1428 };
1429
1430 let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
1431
1432 assert_eq!(sync, SyncState::NoRemote);
1433 }
1434
1435 #[test]
1436 fn resolve_sync_settles_no_upstream_for_a_branch_with_no_tracking_configured() {
1437 let dir = tempfile::tempdir().expect("temp dir");
1438 init_repo_with_a_commit(dir.path());
1439 git(
1440 dir.path(),
1441 &[
1442 "remote",
1443 "add",
1444 "origin",
1445 "https://example.invalid/repo.git",
1446 ],
1447 );
1448 let sha = head_sha(dir.path());
1449 let repo = open_thread_safe(dir.path())
1450 .expect("open")
1451 .to_thread_local();
1452 let head = Head::Branch {
1453 name: Arc::from("main"),
1454 commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
1455 };
1456
1457 let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
1458
1459 assert_eq!(sync, SyncState::NoUpstream);
1460 }
1461
1462 #[test]
1466 fn resolve_sync_settles_no_upstream_when_head_carries_no_branch() {
1467 let dir = tempfile::tempdir().expect("temp dir");
1468 init_repo_with_a_commit(dir.path());
1469 git(
1470 dir.path(),
1471 &[
1472 "remote",
1473 "add",
1474 "origin",
1475 "https://example.invalid/repo.git",
1476 ],
1477 );
1478 let repo = open_thread_safe(dir.path())
1479 .expect("open")
1480 .to_thread_local();
1481
1482 let sync = resolve_sync(&repo, None).expect("resolve sync");
1483
1484 assert_eq!(sync, SyncState::NoUpstream);
1485 }
1486
1487 #[test]
1488 fn resolve_sync_computes_tracking_counts_against_a_live_upstream() {
1489 let dir = tempfile::tempdir().expect("temp dir");
1490 init_repo_with_a_commit(dir.path());
1491 let upstream_sha = head_sha(dir.path());
1492 configure_upstream(dir.path(), "main", &upstream_sha);
1493 git(dir.path(), &["commit", "--allow-empty", "-m", "local work"]);
1494 let tip_sha = head_sha(dir.path());
1495 let repo = open_thread_safe(dir.path())
1496 .expect("open")
1497 .to_thread_local();
1498 let head = Head::Branch {
1499 name: Arc::from("main"),
1500 commit: gix::ObjectId::from_hex(tip_sha.as_bytes()).expect("parse sha"),
1501 };
1502
1503 let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
1504
1505 assert_eq!(
1506 sync,
1507 SyncState::Tracking(AheadBehind {
1508 ahead: 1,
1509 behind: 0
1510 })
1511 );
1512 }
1513
1514 #[test]
1518 fn dirty_counts_reports_distinct_typed_counts_for_modified_untracked_and_deleted_paths() {
1519 let dir = tempfile::tempdir().expect("temp dir");
1520 gix::init(dir.path()).expect("init repo");
1521 std::fs::write(dir.path().join("tracked-modified.txt"), "original\n")
1522 .expect("write tracked file");
1523 std::fs::write(dir.path().join("tracked-deleted-1.txt"), "bye\n")
1524 .expect("write tracked file");
1525 std::fs::write(dir.path().join("tracked-deleted-2.txt"), "bye\n")
1526 .expect("write tracked file");
1527 git(dir.path(), &["add", "."]);
1528 git(dir.path(), &["commit", "-m", "first"]);
1529
1530 std::fs::write(dir.path().join("tracked-modified.txt"), "changed\n")
1532 .expect("modify tracked file");
1533 std::fs::remove_file(dir.path().join("tracked-deleted-1.txt"))
1535 .expect("delete tracked file");
1536 std::fs::remove_file(dir.path().join("tracked-deleted-2.txt"))
1537 .expect("delete tracked file");
1538 for name in ["new-1.txt", "new-2.txt", "new-3.txt"] {
1540 std::fs::write(dir.path().join(name), "x").expect("write untracked file");
1541 }
1542
1543 let repo = open_thread_safe(dir.path())
1544 .expect("open")
1545 .to_thread_local();
1546 let counts =
1547 dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("compute dirty counts");
1548
1549 assert_eq!(
1550 counts,
1551 DirtyCounts {
1552 modified: 1,
1553 untracked: 3,
1554 deleted: 2,
1555 }
1556 );
1557 }
1558
1559 #[test]
1564 fn dirty_counts_reports_a_clean_working_tree_as_all_zero() {
1565 let dir = tempfile::tempdir().expect("temp dir");
1566 init_repo_with_a_commit(dir.path());
1567 let repo = open_thread_safe(dir.path())
1568 .expect("open")
1569 .to_thread_local();
1570
1571 let counts =
1572 dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("compute dirty counts");
1573
1574 assert_eq!(counts, DirtyCounts::default());
1575 }
1576
1577 #[test]
1587 fn should_interrupt_owned_holds_its_own_clone_of_the_cancel_flag() {
1588 let dir = tempfile::tempdir().expect("temp dir");
1589 init_repo_with_a_commit(dir.path());
1590 let repo = open_thread_safe(dir.path())
1591 .expect("open")
1592 .to_thread_local();
1593 let cancel = Arc::new(AtomicBool::new(false));
1594 let before = Arc::strong_count(&cancel);
1595
1596 let platform = repo
1597 .status(gix::progress::Discard)
1598 .expect("status platform")
1599 .should_interrupt_owned(Arc::clone(&cancel));
1600
1601 assert_eq!(
1602 Arc::strong_count(&cancel),
1603 before + 1,
1604 "should_interrupt_owned must hold its own clone of the cancel flag for the \
1605 platform's lifetime, not merely borrow it"
1606 );
1607 drop(platform);
1608 assert_eq!(
1609 Arc::strong_count(&cancel),
1610 before,
1611 "dropping the platform must release its clone rather than leaking it"
1612 );
1613 }
1614
1615 fn repository_with_one_commit() -> tempfile::TempDir {
1621 let dir = tempfile::tempdir().expect("temp dir");
1622 gix::init(dir.path()).expect("init");
1623 git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
1624 dir
1625 }
1626
1627 fn opened(path: &Path) -> gix::Repository {
1628 open_thread_safe(path).expect("open").to_thread_local()
1629 }
1630
1631 #[test]
1634 fn a_repository_with_no_remote_ref_at_all_has_every_commit_unpushed() {
1635 let dir = repository_with_one_commit();
1636
1637 let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1638
1639 assert_eq!((commits, branches), (1, 1));
1640 }
1641
1642 #[test]
1645 fn a_commit_a_remote_tracking_ref_already_carries_is_not_unpushed() {
1646 let dir = repository_with_one_commit();
1647 let sha = head_sha(dir.path());
1648 git(
1649 dir.path(),
1650 &["update-ref", "refs/remotes/origin/main", &sha],
1651 );
1652
1653 let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1654
1655 assert_eq!((commits, branches), (0, 0));
1656 }
1657
1658 #[test]
1661 fn unpushed_counts_commits_once_and_names_every_branch_carrying_one() {
1662 let dir = repository_with_one_commit();
1663 let sha = head_sha(dir.path());
1664 git(
1665 dir.path(),
1666 &["update-ref", "refs/remotes/origin/main", &sha],
1667 );
1668 git(dir.path(), &["commit", "--allow-empty", "-m", "second"]);
1669 git(dir.path(), &["branch", "sidecar"]);
1670 git(dir.path(), &["checkout", "sidecar"]);
1671 git(dir.path(), &["commit", "--allow-empty", "-m", "third"]);
1672
1673 let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1674
1675 assert_eq!(
1676 (commits, branches),
1677 (2, 2),
1678 "the commit both branches carry counts once, not once per branch, and both \
1679 branches carrying one are named"
1680 );
1681 }
1682
1683 #[test]
1686 fn an_unborn_repository_has_nothing_unpushed() {
1687 let dir = tempfile::tempdir().expect("temp dir");
1688 gix::init(dir.path()).expect("init");
1689
1690 let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1691
1692 assert_eq!((commits, branches), (0, 0));
1693 }
1694
1695 #[test]
1700 fn ignored_directories_for_deletion_collapses_an_ignored_tree_to_its_own_root() {
1701 let dir = tempfile::tempdir().expect("temp dir");
1702 let root = dir.path().canonicalize().expect("canonicalize temp dir");
1703 init_repo_with_a_commit(&root);
1704 std::fs::write(root.join(".gitignore"), "node_modules/\n").expect("write .gitignore");
1705 std::fs::create_dir_all(root.join("node_modules").join("a-package"))
1706 .expect("create node_modules");
1707 std::fs::write(
1708 root.join("node_modules").join("a-package").join("index.js"),
1709 "module.exports = {};\n",
1710 )
1711 .expect("write nested file");
1712 git(&root, &["add", ".gitignore"]);
1713 git(&root, &["commit", "-m", "ignore node_modules"]);
1714
1715 let ignored = ignored_directories_for_deletion(&opened(&root)).expect("enumerate ignored");
1716
1717 assert_eq!(ignored, vec![root.join("node_modules")]);
1718 }
1719
1720 #[test]
1723 fn ignored_directories_for_deletion_is_empty_with_no_gitignore() {
1724 let dir = tempfile::tempdir().expect("temp dir");
1725 let root = dir.path().canonicalize().expect("canonicalize temp dir");
1726 init_repo_with_a_commit(&root);
1727 std::fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file");
1728 git(&root, &["add", "tracked.txt"]);
1729 git(&root, &["commit", "-m", "add tracked file"]);
1730 std::fs::write(root.join("untracked.txt"), "untracked\n").expect("write untracked file");
1731
1732 let ignored = ignored_directories_for_deletion(&opened(&root)).expect("enumerate ignored");
1733
1734 assert_eq!(ignored, Vec::<PathBuf>::new());
1735 }
1736
1737 #[test]
1740 fn ignored_directories_for_deletion_on_a_bare_repository_is_empty() {
1741 let dir = tempfile::tempdir().expect("temp dir");
1742 gix::init_bare(dir.path()).expect("init bare");
1743
1744 let ignored =
1745 ignored_directories_for_deletion(&opened(dir.path())).expect("enumerate ignored");
1746
1747 assert_eq!(ignored, Vec::<PathBuf>::new());
1748 }
1749}