1use std::cell::OnceCell;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use super::error::GitError;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum RepoKind {
21 Main,
23 LinkedWorktree { name: String },
26 Bare,
29 Submodule,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum Head {
39 Branch(String),
42 Detached(gix::ObjectId),
44 Unborn { symbolic_ref: String },
47 OtherRef { full_name: String },
50}
51
52impl Head {
53 #[must_use]
55 pub fn kind_str(&self) -> &'static str {
56 match self {
57 Self::Branch(_) => "branch",
58 Self::Detached(_) => "detached",
59 Self::Unborn { .. } => "unborn",
60 Self::OtherRef { .. } => "other_ref",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum DirtyState {
78 #[default]
79 Clean,
80 Dirty(Option<DirtyCounts>),
81}
82
83impl DirtyState {
84 #[must_use]
86 pub fn is_dirty(&self) -> bool {
87 matches!(self, Self::Dirty(_))
88 }
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct DirtyCounts {
96 pub staged: u32,
97 pub unstaged: u32,
98 pub untracked: u32,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct UpstreamState {
105 pub ahead: u32,
106 pub behind: u32,
107 pub upstream_branch: String,
108}
109
110#[non_exhaustive]
115pub struct GitContext {
116 pub repo_kind: RepoKind,
118 pub repo_path: PathBuf,
122 pub head: Head,
124
125 dirty: OnceCell<Arc<DirtyState>>,
126 upstream: OnceCell<Arc<Option<UpstreamState>>>,
127 repo: Option<gix::Repository>,
131}
132
133impl std::fmt::Debug for GitContext {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("GitContext")
136 .field("repo_kind", &self.repo_kind)
137 .field("repo_path", &self.repo_path)
138 .field("head", &self.head)
139 .field("dirty", &self.dirty.get().map(|arc| &**arc))
140 .field("upstream", &self.upstream.get().map(|arc| &**arc))
141 .finish_non_exhaustive()
142 }
143}
144
145impl GitContext {
146 #[must_use]
152 pub fn new(repo_kind: RepoKind, repo_path: PathBuf, head: Head) -> Self {
153 Self {
154 repo_kind,
155 repo_path,
156 head,
157 dirty: OnceCell::new(),
158 upstream: OnceCell::new(),
159 repo: None,
160 }
161 }
162
163 #[must_use]
170 pub fn dirty(&self) -> Arc<DirtyState> {
171 self.dirty
172 .get_or_init(|| match &self.repo {
173 Some(repo) => Arc::new(compute_dirty(repo).unwrap_or_else(|err| {
174 crate::lsm_warn!("git dirty scan failed: {err}");
178 DirtyState::Clean
179 })),
180 None => Arc::new(DirtyState::Clean),
181 })
182 .clone()
183 }
184
185 pub fn preseed_upstream(
190 &self,
191 value: Option<UpstreamState>,
192 ) -> Result<(), Arc<Option<UpstreamState>>> {
193 self.upstream.set(Arc::new(value))
194 }
195
196 pub fn preseed_dirty_state(&self, value: DirtyState) -> Result<(), Arc<DirtyState>> {
200 self.dirty.set(Arc::new(value))
201 }
202
203 #[must_use]
222 pub fn upstream(&self) -> Arc<Option<UpstreamState>> {
223 self.upstream
224 .get_or_init(|| match &self.repo {
225 Some(repo) => Arc::new(compute_upstream(repo, &self.head).unwrap_or_else(|err| {
226 crate::lsm_warn!("git ahead/behind scan failed: {err}");
227 None
228 })),
229 None => Arc::new(None),
230 })
231 .clone()
232 }
233}
234
235pub fn resolve_repo(cwd: &Path) -> Result<Option<GitContext>, GitError> {
242 let repo = match gix::discover(cwd) {
243 Ok(r) => r,
244 Err(gix::discover::Error::Discover(inner)) => {
245 use gix::discover::upwards::Error as U;
246 match inner {
247 U::NoGitRepository { .. }
248 | U::NoGitRepositoryWithinCeiling { .. }
249 | U::NoGitRepositoryWithinFs { .. } => return Ok(None),
250 other => {
251 return Err(GitError::CorruptRepo {
252 path: cwd.to_path_buf(),
253 message: other.to_string(),
254 });
255 }
256 }
257 }
258 Err(e) => {
259 return Err(GitError::CorruptRepo {
260 path: cwd.to_path_buf(),
261 message: e.to_string(),
262 });
263 }
264 };
265
266 let repo_kind = classify_kind(&repo);
267 let repo_path = repo.git_dir().to_path_buf();
268 let head = resolve_head(&repo).map_err(|e| GitError::WalkFailed {
269 path: repo_path.clone(),
270 message: e,
271 })?;
272
273 Ok(Some(GitContext {
274 repo_kind,
275 repo_path,
276 head,
277 dirty: OnceCell::new(),
278 upstream: OnceCell::new(),
279 repo: Some(repo),
280 }))
281}
282
283fn compute_dirty(repo: &gix::Repository) -> Result<DirtyState, Box<dyn std::error::Error>> {
287 use gix::status::UntrackedFiles;
288
289 let platform = repo
290 .status(gix::progress::Discard)?
291 .untracked_files(UntrackedFiles::Collapsed)
292 .index_worktree_rewrites(None);
293 for item in platform.into_index_worktree_iter(Vec::new())? {
294 if item.is_ok() {
295 return Ok(DirtyState::Dirty(None));
296 }
297 }
298 Ok(DirtyState::Clean)
299}
300
301fn compute_upstream(
310 repo: &gix::Repository,
311 head: &Head,
312) -> Result<Option<UpstreamState>, Box<dyn std::error::Error>> {
313 let Head::Branch(_) = head else {
314 return Ok(None);
315 };
316
317 if repo.is_shallow() {
318 return Ok(None);
319 }
320
321 let head_ref = match repo.head_ref()? {
322 Some(r) => r,
323 None => return Ok(None),
324 };
325 let upstream_ref_name = match head_ref.remote_tracking_ref_name(gix::remote::Direction::Fetch) {
326 Some(Ok(name)) => name.into_owned(),
327 Some(Err(e)) => return Err(Box::new(e)),
328 None => return Ok(None),
329 };
330
331 let mut upstream_ref = match repo.try_find_reference(upstream_ref_name.as_ref())? {
332 Some(r) => r,
333 None => return Ok(None),
334 };
335 let upstream_oid = upstream_ref.peel_to_id()?.detach();
336 let head_oid = head_ref.id().detach();
337
338 let (ahead, behind) = match repo.merge_base(head_oid, upstream_oid) {
343 Ok(base) => {
344 let base_oid = base.detach();
345 let ahead = count_ancestors_excluding(repo, head_oid, base_oid)?;
346 let behind = count_ancestors_excluding(repo, upstream_oid, base_oid)?;
347 (ahead, behind)
348 }
349 Err(gix::repository::merge_base::Error::NotFound { .. }) => return Ok(None),
353 Err(e) => return Err(Box::new(e)),
354 };
355
356 let full_name = upstream_ref_name.as_bstr().to_string();
357 let upstream_branch = match full_name.strip_prefix("refs/remotes/") {
358 Some(short) => short.to_string(),
359 None => {
360 crate::lsm_warn!(
361 "upstream ref {full_name} is outside refs/remotes/; rendering full refname"
362 );
363 full_name
364 }
365 };
366
367 Ok(Some(UpstreamState {
368 ahead: u32::try_from(ahead).map_err(|_| {
369 Box::<dyn std::error::Error>::from(format!("ahead count {ahead} overflows u32"))
370 })?,
371 behind: u32::try_from(behind).map_err(|_| {
372 Box::<dyn std::error::Error>::from(format!("behind count {behind} overflows u32"))
373 })?,
374 upstream_branch,
375 }))
376}
377
378fn count_ancestors_excluding(
382 repo: &gix::Repository,
383 tip: gix::ObjectId,
384 stop: gix::ObjectId,
385) -> Result<usize, Box<dyn std::error::Error>> {
386 use std::collections::HashSet;
387 if tip == stop {
388 return Ok(0);
389 }
390 let mut excluded: HashSet<gix::ObjectId> = HashSet::new();
391 excluded.insert(stop);
392 for info in repo.rev_walk([stop]).all()? {
393 excluded.insert(info?.id);
394 }
395
396 let mut count = 0usize;
397 for info in repo.rev_walk([tip]).all()? {
398 if !excluded.contains(&info?.id) {
399 count += 1;
400 }
401 }
402 Ok(count)
403}
404
405fn classify_kind(repo: &gix::Repository) -> RepoKind {
406 if repo.is_bare() {
410 return RepoKind::Bare;
411 }
412 match repo.kind() {
413 gix::repository::Kind::Common => RepoKind::Main,
414 gix::repository::Kind::LinkedWorkTree => {
415 let name = repo
418 .git_dir()
419 .file_name()
420 .and_then(|s| s.to_str())
421 .unwrap_or("")
422 .to_string();
423 RepoKind::LinkedWorktree { name }
424 }
425 gix::repository::Kind::Submodule => RepoKind::Submodule,
426 }
427}
428
429fn resolve_head(repo: &gix::Repository) -> Result<Head, String> {
430 let head = repo.head().map_err(|e| e.to_string())?;
431 match head.kind {
432 gix::head::Kind::Symbolic(reference) => {
433 let full = reference.name.as_bstr().to_string();
434 match full.strip_prefix("refs/heads/") {
435 Some(short) => Ok(Head::Branch(short.to_string())),
436 None => Ok(Head::OtherRef { full_name: full }),
437 }
438 }
439 gix::head::Kind::Detached { target, peeled: _ } => Ok(Head::Detached(target)),
440 gix::head::Kind::Unborn(refname) => {
441 let full = refname.as_bstr().to_string();
442 match full.strip_prefix("refs/heads/") {
443 Some(short) => Ok(Head::Unborn {
444 symbolic_ref: short.to_string(),
445 }),
446 None => Ok(Head::OtherRef { full_name: full }),
447 }
448 }
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use std::fs;
456 use tempfile::TempDir;
457
458 fn init_repo(dir: &Path) -> gix::Repository {
459 gix::init(dir).expect("gix::init")
460 }
461
462 #[test]
463 fn non_repo_directory_returns_ok_none() {
464 let tmp = TempDir::new().expect("tmp");
465 let sub = tmp.path().join("nested");
469 fs::create_dir_all(&sub).expect("mkdir");
470 assert!(resolve_repo(&sub).expect("resolve").is_none());
471 }
472
473 #[test]
474 fn main_checkout_classifies_as_main() {
475 let tmp = TempDir::new().expect("tmp");
476 init_repo(tmp.path());
477 let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
478 assert_eq!(ctx.repo_kind, RepoKind::Main);
479 }
480
481 #[test]
482 fn bare_repo_classifies_as_bare() {
483 let tmp = TempDir::new().expect("tmp");
484 gix::init_bare(tmp.path()).expect("init_bare");
485 let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
486 assert_eq!(ctx.repo_kind, RepoKind::Bare);
487 }
488
489 fn hand_built_linked_worktree(name: &str, primary: &Path, wt_root: &Path) -> PathBuf {
494 let primary_git = primary.join(".git");
495 fs::create_dir_all(primary_git.join("refs/heads")).expect("mkdir refs/heads");
496 fs::create_dir_all(primary_git.join("objects")).expect("mkdir objects");
497 fs::write(primary_git.join("HEAD"), "ref: refs/heads/main\n").expect("write primary HEAD");
498
499 let admin_dir = primary_git.join("worktrees").join(name);
500 fs::create_dir_all(&admin_dir).expect("mkdir admin");
501 let worktree_branch = format!("wt-{name}");
502 fs::write(
503 admin_dir.join("HEAD"),
504 format!("ref: refs/heads/{worktree_branch}\n"),
505 )
506 .expect("write admin HEAD");
507 fs::write(admin_dir.join("commondir"), "../..\n").expect("write commondir");
508
509 let worktree_dir = wt_root.join(name);
510 fs::create_dir_all(&worktree_dir).expect("mkdir worktree");
511 fs::write(
512 admin_dir.join("gitdir"),
513 format!("{}\n", worktree_dir.join(".git").display()),
514 )
515 .expect("write gitdir");
516
517 fs::write(
518 worktree_dir.join(".git"),
519 format!("gitdir: {}\n", admin_dir.display()),
520 )
521 .expect("write .git pointer");
522
523 worktree_dir
524 }
525
526 #[test]
527 fn resolve_repo_classifies_hand_built_linked_worktree() {
528 let primary_tmp = TempDir::new().expect("primary");
529 let wt_tmp = TempDir::new().expect("wt root");
530 let worktree = hand_built_linked_worktree("feat-abc", primary_tmp.path(), wt_tmp.path());
531
532 let ctx = resolve_repo(&worktree).expect("resolve").expect("some");
533
534 let RepoKind::LinkedWorktree { name } = &ctx.repo_kind else {
535 panic!("expected LinkedWorktree, got {:?}", ctx.repo_kind);
536 };
537 assert_eq!(name, "feat-abc");
538 assert!(
539 ctx.repo_path.ends_with("worktrees/feat-abc"),
540 "repo_path should point at the per-worktree admin dir, got {:?}",
541 ctx.repo_path
542 );
543 match &ctx.head {
544 Head::Unborn { symbolic_ref } => assert_eq!(
545 symbolic_ref, "wt-feat-abc",
546 "head must come from the worktree admin HEAD, not the primary's"
547 ),
548 other => panic!("expected Unborn(wt-feat-abc), got {other:?}"),
549 }
550 }
551
552 #[test]
553 fn classify_kind_returns_basename_for_real_linked_worktree() {
554 let primary = TempDir::new().expect("primary");
555 let wt_parent = TempDir::new().expect("wt parent");
556 run_git_init(primary.path());
557 run_git_commit_allow_empty(primary.path(), "seed");
558 let worktree_dir = wt_parent.path().join("feat-real-wt");
559 run_git(
560 primary.path(),
561 &[
562 "worktree",
563 "add",
564 "--quiet",
565 "-b",
566 "feat-real-wt",
567 worktree_dir.to_str().expect("utf8 path"),
568 ],
569 );
570
571 let ctx = resolve_repo(&worktree_dir).expect("resolve").expect("some");
572 let RepoKind::LinkedWorktree { name } = &ctx.repo_kind else {
573 panic!("expected LinkedWorktree, got {:?}", ctx.repo_kind);
574 };
575 assert_eq!(name, "feat-real-wt");
576 match &ctx.head {
577 Head::Branch(b) => assert_eq!(b, "feat-real-wt"),
578 other => panic!("expected Branch(feat-real-wt), got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn unborn_head_reports_symbolic_ref_target() {
584 let tmp = TempDir::new().expect("tmp");
585 init_repo(tmp.path());
586 let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
587 match &ctx.head {
588 Head::Unborn { symbolic_ref } => {
589 assert!(
593 symbolic_ref == "main" || symbolic_ref == "master",
594 "unexpected default branch: {symbolic_ref}"
595 );
596 }
597 other => panic!("expected Unborn, got {other:?}"),
598 }
599 }
600
601 #[test]
602 fn dirty_is_clean_when_no_gix_repo_held() {
603 let ctx = GitContext::new(
604 RepoKind::Main,
605 PathBuf::from("/tmp/.git"),
606 Head::Branch("main".into()),
607 );
608 assert_eq!(*ctx.dirty(), DirtyState::Clean);
609 }
610
611 fn fixture_with_commit(tmp: &TempDir) -> &Path {
615 use std::fs;
616 let path = tmp.path();
617 run_git_init(path);
621 run_git_commit_allow_empty(path, "seed");
622 fs::write(path.join("tracked.txt"), "v1").expect("write");
623 run_git(path, &["add", "tracked.txt"]);
624 run_git_commit(path, "tracked");
625 path
626 }
627
628 fn run_git_init(path: &Path) {
629 use std::process::Command;
630 let mut cmd = Command::new("git");
631 isolated_git_env(&mut cmd);
632 let status = cmd
633 .args(["init", "--quiet", "--initial-branch=main"])
634 .current_dir(path)
635 .status()
636 .expect("git init");
637 assert!(status.success(), "git init failed in {path:?}");
638 }
639
640 fn run_git_init_bare(path: &Path) {
641 use std::process::Command;
642 let mut cmd = Command::new("git");
643 isolated_git_env(&mut cmd);
644 let status = cmd
645 .args(["init", "--bare", "--quiet", "--initial-branch=main"])
646 .current_dir(path)
647 .status()
648 .expect("git init --bare");
649 assert!(status.success(), "git init --bare failed in {path:?}");
650 }
651
652 fn run_git_commit_allow_empty(cwd: &Path, msg: &str) {
653 use std::process::Command;
654 let mut cmd = Command::new("git");
655 isolated_git_env(&mut cmd);
656 let status = cmd
657 .args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
658 .arg(cwd)
659 .args(["commit", "--allow-empty", "-m", msg, "--quiet"])
660 .status()
661 .expect("git commit");
662 assert!(
663 status.success(),
664 "git commit --allow-empty failed in {cwd:?}"
665 );
666 }
667
668 #[test]
669 fn dirty_detects_untracked_file() {
670 use std::fs;
671 let tmp = TempDir::new().expect("tmp");
672 let path = fixture_with_commit(&tmp);
673 fs::write(path.join("new.txt"), "hello").expect("write");
674 let ctx = resolve_repo(path).expect("resolve").expect("some");
675 assert!(
676 ctx.dirty().is_dirty(),
677 "expected dirty on untracked, got {:?}",
678 ctx.dirty()
679 );
680 }
681
682 #[test]
683 fn dirty_detects_modified_tracked_file() {
684 use std::fs;
685 let tmp = TempDir::new().expect("tmp");
686 let path = fixture_with_commit(&tmp);
687 fs::write(path.join("tracked.txt"), "modified").expect("write");
688 let ctx = resolve_repo(path).expect("resolve").expect("some");
689 assert!(
690 ctx.dirty().is_dirty(),
691 "expected dirty on modified tracked, got {:?}",
692 ctx.dirty()
693 );
694 }
695
696 #[test]
697 fn dirty_is_clean_on_committed_repo_with_no_changes() {
698 let tmp = TempDir::new().expect("tmp");
699 let path = fixture_with_commit(&tmp);
700 let ctx = resolve_repo(path).expect("resolve").expect("some");
701 assert_eq!(*ctx.dirty(), DirtyState::Clean);
702 }
703
704 #[test]
705 fn upstream_is_none_when_no_gix_repo_held() {
706 let ctx = GitContext::new(
707 RepoKind::Main,
708 PathBuf::from("/tmp/.git"),
709 Head::Branch("main".into()),
710 );
711 assert!(ctx.upstream().is_none());
712 }
713
714 #[test]
715 fn upstream_is_none_when_no_tracking_branch_configured() {
716 let tmp = TempDir::new().expect("tmp");
717 let path = fixture_with_commit(&tmp);
718 let ctx = resolve_repo(path).expect("resolve").expect("some");
719 assert!(
720 ctx.upstream().is_none(),
721 "expected None without upstream, got {:?}",
722 ctx.upstream()
723 );
724 }
725
726 fn fixture_with_upstream<'a>(
731 local: &'a TempDir,
732 remote: &'a TempDir,
733 local_commits: usize,
734 remote_commits: usize,
735 ) -> &'a Path {
736 use std::fs;
737 use std::process::Command;
738 let bare = remote.path();
739 let path = local.path();
740 run_git_init_bare(bare);
741 run_git_init(path);
742 fs::write(path.join("f"), "base").expect("write base");
743 run_git(path, &["add", "f"]);
744 run_git_commit(path, "base");
745 run_git(
746 path,
747 &["remote", "add", "origin", bare.to_str().expect("utf8 path")],
748 );
749 run_git(path, &["push", "-u", "origin", "main", "--quiet"]);
750 for i in 0..local_commits {
751 fs::write(path.join("f"), format!("local-{i}")).expect("write");
752 run_git(path, &["add", "f"]);
753 run_git_commit(path, &format!("local {i}"));
754 }
755 if remote_commits > 0 {
759 let other_tmp = TempDir::new().expect("other tmp");
760 let other = other_tmp.path().join("clone");
761 let mut clone_cmd = Command::new("git");
762 isolated_git_env(&mut clone_cmd);
763 let status = clone_cmd
764 .args(["clone", "--quiet"])
765 .arg(bare)
766 .arg(&other)
767 .status()
768 .expect("clone");
769 assert!(status.success(), "git clone failed");
770 for i in 0..remote_commits {
771 fs::write(other.join("g"), format!("remote-{i}")).expect("write");
772 run_git(&other, &["add", "g"]);
773 run_git_commit(&other, &format!("remote {i}"));
774 }
775 run_git(&other, &["push", "--quiet"]);
776 run_git(path, &["fetch", "--quiet"]);
777 drop(other_tmp);
778 }
779 path
780 }
781
782 fn isolated_git_env(cmd: &mut std::process::Command) {
787 cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
788 .env("GIT_CONFIG_SYSTEM", "/dev/null")
789 .env("GIT_CONFIG_NOSYSTEM", "1")
790 .args(["-c", "commit.gpgsign=false"])
791 .args(["-c", "core.hooksPath=/dev/null"])
792 .args(["-c", "init.defaultBranch=main"]);
793 }
794
795 fn run_git(cwd: &Path, args: &[&str]) {
796 use std::process::Command;
797 let mut cmd = Command::new("git");
798 isolated_git_env(&mut cmd);
799 let status = cmd.args(["-C"]).arg(cwd).args(args).status().expect("git");
800 assert!(status.success(), "git {args:?} failed in {cwd:?}");
801 }
802
803 fn run_git_commit(cwd: &Path, msg: &str) {
804 use std::process::Command;
805 let mut cmd = Command::new("git");
806 isolated_git_env(&mut cmd);
807 let status = cmd
808 .args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
809 .arg(cwd)
810 .args(["commit", "-m", msg, "--quiet"])
811 .status()
812 .expect("git commit");
813 assert!(status.success(), "git commit failed in {cwd:?}");
814 }
815
816 #[test]
817 fn upstream_reports_zero_ahead_zero_behind_when_in_sync() {
818 let local = TempDir::new().expect("local");
819 let remote = TempDir::new().expect("remote");
820 let path = fixture_with_upstream(&local, &remote, 0, 0);
821 let ctx = resolve_repo(path).expect("resolve").expect("some");
822 let upstream = ctx.upstream();
823 let state = upstream.as_ref().as_ref().expect("some upstream");
824 assert_eq!(state.ahead, 0);
825 assert_eq!(state.behind, 0);
826 assert_eq!(state.upstream_branch, "origin/main");
827 }
828
829 #[test]
830 fn upstream_reports_ahead_only_when_local_leads() {
831 let local = TempDir::new().expect("local");
832 let remote = TempDir::new().expect("remote");
833 let path = fixture_with_upstream(&local, &remote, 2, 0);
834 let ctx = resolve_repo(path).expect("resolve").expect("some");
835 let upstream = ctx.upstream();
836 let state = upstream.as_ref().as_ref().expect("some upstream");
837 assert_eq!(state.ahead, 2);
838 assert_eq!(state.behind, 0);
839 }
840
841 #[test]
842 fn upstream_reports_behind_only_when_remote_leads() {
843 let local = TempDir::new().expect("local");
844 let remote = TempDir::new().expect("remote");
845 let path = fixture_with_upstream(&local, &remote, 0, 3);
846 let ctx = resolve_repo(path).expect("resolve").expect("some");
847 let upstream = ctx.upstream();
848 let state = upstream.as_ref().as_ref().expect("some upstream");
849 assert_eq!(state.ahead, 0);
850 assert_eq!(state.behind, 3);
851 }
852
853 #[test]
854 fn upstream_reports_both_when_diverged() {
855 let local = TempDir::new().expect("local");
856 let remote = TempDir::new().expect("remote");
857 let path = fixture_with_upstream(&local, &remote, 2, 3);
858 let ctx = resolve_repo(path).expect("resolve").expect("some");
859 let upstream = ctx.upstream();
860 let state = upstream.as_ref().as_ref().expect("some upstream");
861 assert_eq!(state.ahead, 2);
862 assert_eq!(state.behind, 3);
863 }
864
865 #[test]
866 fn upstream_is_none_on_detached_head() {
867 let tmp = TempDir::new().expect("tmp");
868 let path = fixture_with_commit(&tmp);
869 run_git(path, &["checkout", "--detach", "HEAD"]);
871 let ctx = resolve_repo(path).expect("resolve").expect("some");
872 assert!(matches!(ctx.head, Head::Detached(_)));
873 assert!(ctx.upstream().is_none());
874 }
875
876 #[test]
877 fn head_kind_str_covers_every_variant() {
878 assert_eq!(Head::Branch("x".into()).kind_str(), "branch");
879 assert_eq!(
880 Head::Detached(gix::ObjectId::null(gix::hash::Kind::Sha1)).kind_str(),
881 "detached"
882 );
883 assert_eq!(
884 Head::Unborn {
885 symbolic_ref: "main".into()
886 }
887 .kind_str(),
888 "unborn"
889 );
890 assert_eq!(
891 Head::OtherRef {
892 full_name: "refs/remotes/origin/main".into()
893 }
894 .kind_str(),
895 "other_ref"
896 );
897 }
898}