1use std::cell::OnceCell;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use super::errors::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 #[test]
490 fn unborn_head_reports_symbolic_ref_target() {
491 let tmp = TempDir::new().expect("tmp");
492 init_repo(tmp.path());
493 let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
494 match &ctx.head {
495 Head::Unborn { symbolic_ref } => {
496 assert!(
500 symbolic_ref == "main" || symbolic_ref == "master",
501 "unexpected default branch: {symbolic_ref}"
502 );
503 }
504 other => panic!("expected Unborn, got {other:?}"),
505 }
506 }
507
508 #[test]
509 fn dirty_is_clean_when_no_gix_repo_held() {
510 let ctx = GitContext::new(
511 RepoKind::Main,
512 PathBuf::from("/tmp/.git"),
513 Head::Branch("main".into()),
514 );
515 assert_eq!(*ctx.dirty(), DirtyState::Clean);
516 }
517
518 fn fixture_with_commit(tmp: &TempDir) -> &Path {
522 use std::fs;
523 let path = tmp.path();
524 run_git_init(path);
528 run_git_commit_allow_empty(path, "seed");
529 fs::write(path.join("tracked.txt"), "v1").expect("write");
530 run_git(path, &["add", "tracked.txt"]);
531 run_git_commit(path, "tracked");
532 path
533 }
534
535 fn run_git_init(path: &Path) {
536 use std::process::Command;
537 let mut cmd = Command::new("git");
538 isolated_git_env(&mut cmd);
539 let status = cmd
540 .args(["init", "--quiet", "--initial-branch=main"])
541 .current_dir(path)
542 .status()
543 .expect("git init");
544 assert!(status.success(), "git init failed in {path:?}");
545 }
546
547 fn run_git_init_bare(path: &Path) {
548 use std::process::Command;
549 let mut cmd = Command::new("git");
550 isolated_git_env(&mut cmd);
551 let status = cmd
552 .args(["init", "--bare", "--quiet", "--initial-branch=main"])
553 .current_dir(path)
554 .status()
555 .expect("git init --bare");
556 assert!(status.success(), "git init --bare failed in {path:?}");
557 }
558
559 fn run_git_commit_allow_empty(cwd: &Path, msg: &str) {
560 use std::process::Command;
561 let mut cmd = Command::new("git");
562 isolated_git_env(&mut cmd);
563 let status = cmd
564 .args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
565 .arg(cwd)
566 .args(["commit", "--allow-empty", "-m", msg, "--quiet"])
567 .status()
568 .expect("git commit");
569 assert!(
570 status.success(),
571 "git commit --allow-empty failed in {cwd:?}"
572 );
573 }
574
575 #[test]
576 fn dirty_detects_untracked_file() {
577 use std::fs;
578 let tmp = TempDir::new().expect("tmp");
579 let path = fixture_with_commit(&tmp);
580 fs::write(path.join("new.txt"), "hello").expect("write");
581 let ctx = resolve_repo(path).expect("resolve").expect("some");
582 assert!(
583 ctx.dirty().is_dirty(),
584 "expected dirty on untracked, got {:?}",
585 ctx.dirty()
586 );
587 }
588
589 #[test]
590 fn dirty_detects_modified_tracked_file() {
591 use std::fs;
592 let tmp = TempDir::new().expect("tmp");
593 let path = fixture_with_commit(&tmp);
594 fs::write(path.join("tracked.txt"), "modified").expect("write");
595 let ctx = resolve_repo(path).expect("resolve").expect("some");
596 assert!(
597 ctx.dirty().is_dirty(),
598 "expected dirty on modified tracked, got {:?}",
599 ctx.dirty()
600 );
601 }
602
603 #[test]
604 fn dirty_is_clean_on_committed_repo_with_no_changes() {
605 let tmp = TempDir::new().expect("tmp");
606 let path = fixture_with_commit(&tmp);
607 let ctx = resolve_repo(path).expect("resolve").expect("some");
608 assert_eq!(*ctx.dirty(), DirtyState::Clean);
609 }
610
611 #[test]
612 fn upstream_is_none_when_no_gix_repo_held() {
613 let ctx = GitContext::new(
614 RepoKind::Main,
615 PathBuf::from("/tmp/.git"),
616 Head::Branch("main".into()),
617 );
618 assert!(ctx.upstream().is_none());
619 }
620
621 #[test]
622 fn upstream_is_none_when_no_tracking_branch_configured() {
623 let tmp = TempDir::new().expect("tmp");
624 let path = fixture_with_commit(&tmp);
625 let ctx = resolve_repo(path).expect("resolve").expect("some");
626 assert!(
627 ctx.upstream().is_none(),
628 "expected None without upstream, got {:?}",
629 ctx.upstream()
630 );
631 }
632
633 fn fixture_with_upstream<'a>(
638 local: &'a TempDir,
639 remote: &'a TempDir,
640 local_commits: usize,
641 remote_commits: usize,
642 ) -> &'a Path {
643 use std::fs;
644 use std::process::Command;
645 let bare = remote.path();
646 let path = local.path();
647 run_git_init_bare(bare);
648 run_git_init(path);
649 fs::write(path.join("f"), "base").expect("write base");
650 run_git(path, &["add", "f"]);
651 run_git_commit(path, "base");
652 run_git(
653 path,
654 &["remote", "add", "origin", bare.to_str().expect("utf8 path")],
655 );
656 run_git(path, &["push", "-u", "origin", "main", "--quiet"]);
657 for i in 0..local_commits {
658 fs::write(path.join("f"), format!("local-{i}")).expect("write");
659 run_git(path, &["add", "f"]);
660 run_git_commit(path, &format!("local {i}"));
661 }
662 if remote_commits > 0 {
666 let other_tmp = TempDir::new().expect("other tmp");
667 let other = other_tmp.path().join("clone");
668 let mut clone_cmd = Command::new("git");
669 isolated_git_env(&mut clone_cmd);
670 let status = clone_cmd
671 .args(["clone", "--quiet"])
672 .arg(bare)
673 .arg(&other)
674 .status()
675 .expect("clone");
676 assert!(status.success(), "git clone failed");
677 for i in 0..remote_commits {
678 fs::write(other.join("g"), format!("remote-{i}")).expect("write");
679 run_git(&other, &["add", "g"]);
680 run_git_commit(&other, &format!("remote {i}"));
681 }
682 run_git(&other, &["push", "--quiet"]);
683 run_git(path, &["fetch", "--quiet"]);
684 drop(other_tmp);
685 }
686 path
687 }
688
689 fn isolated_git_env(cmd: &mut std::process::Command) {
694 cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
695 .env("GIT_CONFIG_SYSTEM", "/dev/null")
696 .env("GIT_CONFIG_NOSYSTEM", "1")
697 .args(["-c", "commit.gpgsign=false"])
698 .args(["-c", "core.hooksPath=/dev/null"])
699 .args(["-c", "init.defaultBranch=main"]);
700 }
701
702 fn run_git(cwd: &Path, args: &[&str]) {
703 use std::process::Command;
704 let mut cmd = Command::new("git");
705 isolated_git_env(&mut cmd);
706 let status = cmd.args(["-C"]).arg(cwd).args(args).status().expect("git");
707 assert!(status.success(), "git {args:?} failed in {cwd:?}");
708 }
709
710 fn run_git_commit(cwd: &Path, msg: &str) {
711 use std::process::Command;
712 let mut cmd = Command::new("git");
713 isolated_git_env(&mut cmd);
714 let status = cmd
715 .args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
716 .arg(cwd)
717 .args(["commit", "-m", msg, "--quiet"])
718 .status()
719 .expect("git commit");
720 assert!(status.success(), "git commit failed in {cwd:?}");
721 }
722
723 #[test]
724 fn upstream_reports_zero_ahead_zero_behind_when_in_sync() {
725 let local = TempDir::new().expect("local");
726 let remote = TempDir::new().expect("remote");
727 let path = fixture_with_upstream(&local, &remote, 0, 0);
728 let ctx = resolve_repo(path).expect("resolve").expect("some");
729 let upstream = ctx.upstream();
730 let state = upstream.as_ref().as_ref().expect("some upstream");
731 assert_eq!(state.ahead, 0);
732 assert_eq!(state.behind, 0);
733 assert_eq!(state.upstream_branch, "origin/main");
734 }
735
736 #[test]
737 fn upstream_reports_ahead_only_when_local_leads() {
738 let local = TempDir::new().expect("local");
739 let remote = TempDir::new().expect("remote");
740 let path = fixture_with_upstream(&local, &remote, 2, 0);
741 let ctx = resolve_repo(path).expect("resolve").expect("some");
742 let upstream = ctx.upstream();
743 let state = upstream.as_ref().as_ref().expect("some upstream");
744 assert_eq!(state.ahead, 2);
745 assert_eq!(state.behind, 0);
746 }
747
748 #[test]
749 fn upstream_reports_behind_only_when_remote_leads() {
750 let local = TempDir::new().expect("local");
751 let remote = TempDir::new().expect("remote");
752 let path = fixture_with_upstream(&local, &remote, 0, 3);
753 let ctx = resolve_repo(path).expect("resolve").expect("some");
754 let upstream = ctx.upstream();
755 let state = upstream.as_ref().as_ref().expect("some upstream");
756 assert_eq!(state.ahead, 0);
757 assert_eq!(state.behind, 3);
758 }
759
760 #[test]
761 fn upstream_reports_both_when_diverged() {
762 let local = TempDir::new().expect("local");
763 let remote = TempDir::new().expect("remote");
764 let path = fixture_with_upstream(&local, &remote, 2, 3);
765 let ctx = resolve_repo(path).expect("resolve").expect("some");
766 let upstream = ctx.upstream();
767 let state = upstream.as_ref().as_ref().expect("some upstream");
768 assert_eq!(state.ahead, 2);
769 assert_eq!(state.behind, 3);
770 }
771
772 #[test]
773 fn upstream_is_none_on_detached_head() {
774 let tmp = TempDir::new().expect("tmp");
775 let path = fixture_with_commit(&tmp);
776 run_git(path, &["checkout", "--detach", "HEAD"]);
778 let ctx = resolve_repo(path).expect("resolve").expect("some");
779 assert!(matches!(ctx.head, Head::Detached(_)));
780 assert!(ctx.upstream().is_none());
781 }
782
783 #[test]
784 fn head_kind_str_covers_every_variant() {
785 assert_eq!(Head::Branch("x".into()).kind_str(), "branch");
786 assert_eq!(
787 Head::Detached(gix::ObjectId::null(gix::hash::Kind::Sha1)).kind_str(),
788 "detached"
789 );
790 assert_eq!(
791 Head::Unborn {
792 symbolic_ref: "main".into()
793 }
794 .kind_str(),
795 "unborn"
796 );
797 assert_eq!(
798 Head::OtherRef {
799 full_name: "refs/remotes/origin/main".into()
800 }
801 .kind_str(),
802 "other_ref"
803 );
804 }
805}