use std::cell::OnceCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::errors::GitError;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RepoKind {
Main,
LinkedWorktree { name: String },
Bare,
Submodule,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Head {
Branch(String),
Detached(gix::ObjectId),
Unborn { symbolic_ref: String },
OtherRef { full_name: String },
}
impl Head {
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::Branch(_) => "branch",
Self::Detached(_) => "detached",
Self::Unborn { .. } => "unborn",
Self::OtherRef { .. } => "other_ref",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirtyState {
#[default]
Clean,
Dirty(Option<DirtyCounts>),
}
impl DirtyState {
#[must_use]
pub fn is_dirty(&self) -> bool {
matches!(self, Self::Dirty(_))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DirtyCounts {
pub staged: u32,
pub unstaged: u32,
pub untracked: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UpstreamState {
pub ahead: u32,
pub behind: u32,
pub upstream_branch: String,
}
#[non_exhaustive]
pub struct GitContext {
pub repo_kind: RepoKind,
pub repo_path: PathBuf,
pub head: Head,
dirty: OnceCell<Arc<DirtyState>>,
upstream: OnceCell<Arc<Option<UpstreamState>>>,
repo: Option<gix::Repository>,
}
impl std::fmt::Debug for GitContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitContext")
.field("repo_kind", &self.repo_kind)
.field("repo_path", &self.repo_path)
.field("head", &self.head)
.field("dirty", &self.dirty.get().map(|arc| &**arc))
.field("upstream", &self.upstream.get().map(|arc| &**arc))
.finish_non_exhaustive()
}
}
impl GitContext {
#[must_use]
pub fn new(repo_kind: RepoKind, repo_path: PathBuf, head: Head) -> Self {
Self {
repo_kind,
repo_path,
head,
dirty: OnceCell::new(),
upstream: OnceCell::new(),
repo: None,
}
}
#[must_use]
pub fn dirty(&self) -> Arc<DirtyState> {
self.dirty
.get_or_init(|| match &self.repo {
Some(repo) => Arc::new(compute_dirty(repo).unwrap_or_else(|err| {
crate::lsm_warn!("git dirty scan failed: {err}");
DirtyState::Clean
})),
None => Arc::new(DirtyState::Clean),
})
.clone()
}
pub fn preseed_upstream(
&self,
value: Option<UpstreamState>,
) -> Result<(), Arc<Option<UpstreamState>>> {
self.upstream.set(Arc::new(value))
}
pub fn preseed_dirty_state(&self, value: DirtyState) -> Result<(), Arc<DirtyState>> {
self.dirty.set(Arc::new(value))
}
#[must_use]
pub fn upstream(&self) -> Arc<Option<UpstreamState>> {
self.upstream
.get_or_init(|| match &self.repo {
Some(repo) => Arc::new(compute_upstream(repo, &self.head).unwrap_or_else(|err| {
crate::lsm_warn!("git ahead/behind scan failed: {err}");
None
})),
None => Arc::new(None),
})
.clone()
}
}
pub fn resolve_repo(cwd: &Path) -> Result<Option<GitContext>, GitError> {
let repo = match gix::discover(cwd) {
Ok(r) => r,
Err(gix::discover::Error::Discover(inner)) => {
use gix::discover::upwards::Error as U;
match inner {
U::NoGitRepository { .. }
| U::NoGitRepositoryWithinCeiling { .. }
| U::NoGitRepositoryWithinFs { .. } => return Ok(None),
other => {
return Err(GitError::CorruptRepo {
path: cwd.to_path_buf(),
message: other.to_string(),
});
}
}
}
Err(e) => {
return Err(GitError::CorruptRepo {
path: cwd.to_path_buf(),
message: e.to_string(),
});
}
};
let repo_kind = classify_kind(&repo);
let repo_path = repo.git_dir().to_path_buf();
let head = resolve_head(&repo).map_err(|e| GitError::WalkFailed {
path: repo_path.clone(),
message: e,
})?;
Ok(Some(GitContext {
repo_kind,
repo_path,
head,
dirty: OnceCell::new(),
upstream: OnceCell::new(),
repo: Some(repo),
}))
}
fn compute_dirty(repo: &gix::Repository) -> Result<DirtyState, Box<dyn std::error::Error>> {
use gix::status::UntrackedFiles;
let platform = repo
.status(gix::progress::Discard)?
.untracked_files(UntrackedFiles::Collapsed)
.index_worktree_rewrites(None);
for item in platform.into_index_worktree_iter(Vec::new())? {
if item.is_ok() {
return Ok(DirtyState::Dirty(None));
}
}
Ok(DirtyState::Clean)
}
fn compute_upstream(
repo: &gix::Repository,
head: &Head,
) -> Result<Option<UpstreamState>, Box<dyn std::error::Error>> {
let Head::Branch(_) = head else {
return Ok(None);
};
if repo.is_shallow() {
return Ok(None);
}
let head_ref = match repo.head_ref()? {
Some(r) => r,
None => return Ok(None),
};
let upstream_ref_name = match head_ref.remote_tracking_ref_name(gix::remote::Direction::Fetch) {
Some(Ok(name)) => name.into_owned(),
Some(Err(e)) => return Err(Box::new(e)),
None => return Ok(None),
};
let mut upstream_ref = match repo.try_find_reference(upstream_ref_name.as_ref())? {
Some(r) => r,
None => return Ok(None),
};
let upstream_oid = upstream_ref.peel_to_id()?.detach();
let head_oid = head_ref.id().detach();
let (ahead, behind) = match repo.merge_base(head_oid, upstream_oid) {
Ok(base) => {
let base_oid = base.detach();
let ahead = count_ancestors_excluding(repo, head_oid, base_oid)?;
let behind = count_ancestors_excluding(repo, upstream_oid, base_oid)?;
(ahead, behind)
}
Err(gix::repository::merge_base::Error::NotFound { .. }) => return Ok(None),
Err(e) => return Err(Box::new(e)),
};
let full_name = upstream_ref_name.as_bstr().to_string();
let upstream_branch = match full_name.strip_prefix("refs/remotes/") {
Some(short) => short.to_string(),
None => {
crate::lsm_warn!(
"upstream ref {full_name} is outside refs/remotes/; rendering full refname"
);
full_name
}
};
Ok(Some(UpstreamState {
ahead: u32::try_from(ahead).map_err(|_| {
Box::<dyn std::error::Error>::from(format!("ahead count {ahead} overflows u32"))
})?,
behind: u32::try_from(behind).map_err(|_| {
Box::<dyn std::error::Error>::from(format!("behind count {behind} overflows u32"))
})?,
upstream_branch,
}))
}
fn count_ancestors_excluding(
repo: &gix::Repository,
tip: gix::ObjectId,
stop: gix::ObjectId,
) -> Result<usize, Box<dyn std::error::Error>> {
use std::collections::HashSet;
if tip == stop {
return Ok(0);
}
let mut excluded: HashSet<gix::ObjectId> = HashSet::new();
excluded.insert(stop);
for info in repo.rev_walk([stop]).all()? {
excluded.insert(info?.id);
}
let mut count = 0usize;
for info in repo.rev_walk([tip]).all()? {
if !excluded.contains(&info?.id) {
count += 1;
}
}
Ok(count)
}
fn classify_kind(repo: &gix::Repository) -> RepoKind {
if repo.is_bare() {
return RepoKind::Bare;
}
match repo.kind() {
gix::repository::Kind::Common => RepoKind::Main,
gix::repository::Kind::LinkedWorkTree => {
let name = repo
.git_dir()
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
RepoKind::LinkedWorktree { name }
}
gix::repository::Kind::Submodule => RepoKind::Submodule,
}
}
fn resolve_head(repo: &gix::Repository) -> Result<Head, String> {
let head = repo.head().map_err(|e| e.to_string())?;
match head.kind {
gix::head::Kind::Symbolic(reference) => {
let full = reference.name.as_bstr().to_string();
match full.strip_prefix("refs/heads/") {
Some(short) => Ok(Head::Branch(short.to_string())),
None => Ok(Head::OtherRef { full_name: full }),
}
}
gix::head::Kind::Detached { target, peeled: _ } => Ok(Head::Detached(target)),
gix::head::Kind::Unborn(refname) => {
let full = refname.as_bstr().to_string();
match full.strip_prefix("refs/heads/") {
Some(short) => Ok(Head::Unborn {
symbolic_ref: short.to_string(),
}),
None => Ok(Head::OtherRef { full_name: full }),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn init_repo(dir: &Path) -> gix::Repository {
gix::init(dir).expect("gix::init")
}
#[test]
fn non_repo_directory_returns_ok_none() {
let tmp = TempDir::new().expect("tmp");
let sub = tmp.path().join("nested");
fs::create_dir_all(&sub).expect("mkdir");
assert!(resolve_repo(&sub).expect("resolve").is_none());
}
#[test]
fn main_checkout_classifies_as_main() {
let tmp = TempDir::new().expect("tmp");
init_repo(tmp.path());
let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
assert_eq!(ctx.repo_kind, RepoKind::Main);
}
#[test]
fn bare_repo_classifies_as_bare() {
let tmp = TempDir::new().expect("tmp");
gix::init_bare(tmp.path()).expect("init_bare");
let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
assert_eq!(ctx.repo_kind, RepoKind::Bare);
}
#[test]
fn unborn_head_reports_symbolic_ref_target() {
let tmp = TempDir::new().expect("tmp");
init_repo(tmp.path());
let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
match &ctx.head {
Head::Unborn { symbolic_ref } => {
assert!(
symbolic_ref == "main" || symbolic_ref == "master",
"unexpected default branch: {symbolic_ref}"
);
}
other => panic!("expected Unborn, got {other:?}"),
}
}
#[test]
fn dirty_is_clean_when_no_gix_repo_held() {
let ctx = GitContext::new(
RepoKind::Main,
PathBuf::from("/tmp/.git"),
Head::Branch("main".into()),
);
assert_eq!(*ctx.dirty(), DirtyState::Clean);
}
fn fixture_with_commit(tmp: &TempDir) -> &Path {
use std::fs;
let path = tmp.path();
run_git_init(path);
run_git_commit_allow_empty(path, "seed");
fs::write(path.join("tracked.txt"), "v1").expect("write");
run_git(path, &["add", "tracked.txt"]);
run_git_commit(path, "tracked");
path
}
fn run_git_init(path: &Path) {
use std::process::Command;
let mut cmd = Command::new("git");
isolated_git_env(&mut cmd);
let status = cmd
.args(["init", "--quiet", "--initial-branch=main"])
.current_dir(path)
.status()
.expect("git init");
assert!(status.success(), "git init failed in {path:?}");
}
fn run_git_init_bare(path: &Path) {
use std::process::Command;
let mut cmd = Command::new("git");
isolated_git_env(&mut cmd);
let status = cmd
.args(["init", "--bare", "--quiet", "--initial-branch=main"])
.current_dir(path)
.status()
.expect("git init --bare");
assert!(status.success(), "git init --bare failed in {path:?}");
}
fn run_git_commit_allow_empty(cwd: &Path, msg: &str) {
use std::process::Command;
let mut cmd = Command::new("git");
isolated_git_env(&mut cmd);
let status = cmd
.args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
.arg(cwd)
.args(["commit", "--allow-empty", "-m", msg, "--quiet"])
.status()
.expect("git commit");
assert!(
status.success(),
"git commit --allow-empty failed in {cwd:?}"
);
}
#[test]
fn dirty_detects_untracked_file() {
use std::fs;
let tmp = TempDir::new().expect("tmp");
let path = fixture_with_commit(&tmp);
fs::write(path.join("new.txt"), "hello").expect("write");
let ctx = resolve_repo(path).expect("resolve").expect("some");
assert!(
ctx.dirty().is_dirty(),
"expected dirty on untracked, got {:?}",
ctx.dirty()
);
}
#[test]
fn dirty_detects_modified_tracked_file() {
use std::fs;
let tmp = TempDir::new().expect("tmp");
let path = fixture_with_commit(&tmp);
fs::write(path.join("tracked.txt"), "modified").expect("write");
let ctx = resolve_repo(path).expect("resolve").expect("some");
assert!(
ctx.dirty().is_dirty(),
"expected dirty on modified tracked, got {:?}",
ctx.dirty()
);
}
#[test]
fn dirty_is_clean_on_committed_repo_with_no_changes() {
let tmp = TempDir::new().expect("tmp");
let path = fixture_with_commit(&tmp);
let ctx = resolve_repo(path).expect("resolve").expect("some");
assert_eq!(*ctx.dirty(), DirtyState::Clean);
}
#[test]
fn upstream_is_none_when_no_gix_repo_held() {
let ctx = GitContext::new(
RepoKind::Main,
PathBuf::from("/tmp/.git"),
Head::Branch("main".into()),
);
assert!(ctx.upstream().is_none());
}
#[test]
fn upstream_is_none_when_no_tracking_branch_configured() {
let tmp = TempDir::new().expect("tmp");
let path = fixture_with_commit(&tmp);
let ctx = resolve_repo(path).expect("resolve").expect("some");
assert!(
ctx.upstream().is_none(),
"expected None without upstream, got {:?}",
ctx.upstream()
);
}
fn fixture_with_upstream<'a>(
local: &'a TempDir,
remote: &'a TempDir,
local_commits: usize,
remote_commits: usize,
) -> &'a Path {
use std::fs;
use std::process::Command;
let bare = remote.path();
let path = local.path();
run_git_init_bare(bare);
run_git_init(path);
fs::write(path.join("f"), "base").expect("write base");
run_git(path, &["add", "f"]);
run_git_commit(path, "base");
run_git(
path,
&["remote", "add", "origin", bare.to_str().expect("utf8 path")],
);
run_git(path, &["push", "-u", "origin", "main", "--quiet"]);
for i in 0..local_commits {
fs::write(path.join("f"), format!("local-{i}")).expect("write");
run_git(path, &["add", "f"]);
run_git_commit(path, &format!("local {i}"));
}
if remote_commits > 0 {
let other_tmp = TempDir::new().expect("other tmp");
let other = other_tmp.path().join("clone");
let mut clone_cmd = Command::new("git");
isolated_git_env(&mut clone_cmd);
let status = clone_cmd
.args(["clone", "--quiet"])
.arg(bare)
.arg(&other)
.status()
.expect("clone");
assert!(status.success(), "git clone failed");
for i in 0..remote_commits {
fs::write(other.join("g"), format!("remote-{i}")).expect("write");
run_git(&other, &["add", "g"]);
run_git_commit(&other, &format!("remote {i}"));
}
run_git(&other, &["push", "--quiet"]);
run_git(path, &["fetch", "--quiet"]);
drop(other_tmp);
}
path
}
fn isolated_git_env(cmd: &mut std::process::Command) {
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(["-c", "commit.gpgsign=false"])
.args(["-c", "core.hooksPath=/dev/null"])
.args(["-c", "init.defaultBranch=main"]);
}
fn run_git(cwd: &Path, args: &[&str]) {
use std::process::Command;
let mut cmd = Command::new("git");
isolated_git_env(&mut cmd);
let status = cmd.args(["-C"]).arg(cwd).args(args).status().expect("git");
assert!(status.success(), "git {args:?} failed in {cwd:?}");
}
fn run_git_commit(cwd: &Path, msg: &str) {
use std::process::Command;
let mut cmd = Command::new("git");
isolated_git_env(&mut cmd);
let status = cmd
.args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
.arg(cwd)
.args(["commit", "-m", msg, "--quiet"])
.status()
.expect("git commit");
assert!(status.success(), "git commit failed in {cwd:?}");
}
#[test]
fn upstream_reports_zero_ahead_zero_behind_when_in_sync() {
let local = TempDir::new().expect("local");
let remote = TempDir::new().expect("remote");
let path = fixture_with_upstream(&local, &remote, 0, 0);
let ctx = resolve_repo(path).expect("resolve").expect("some");
let upstream = ctx.upstream();
let state = upstream.as_ref().as_ref().expect("some upstream");
assert_eq!(state.ahead, 0);
assert_eq!(state.behind, 0);
assert_eq!(state.upstream_branch, "origin/main");
}
#[test]
fn upstream_reports_ahead_only_when_local_leads() {
let local = TempDir::new().expect("local");
let remote = TempDir::new().expect("remote");
let path = fixture_with_upstream(&local, &remote, 2, 0);
let ctx = resolve_repo(path).expect("resolve").expect("some");
let upstream = ctx.upstream();
let state = upstream.as_ref().as_ref().expect("some upstream");
assert_eq!(state.ahead, 2);
assert_eq!(state.behind, 0);
}
#[test]
fn upstream_reports_behind_only_when_remote_leads() {
let local = TempDir::new().expect("local");
let remote = TempDir::new().expect("remote");
let path = fixture_with_upstream(&local, &remote, 0, 3);
let ctx = resolve_repo(path).expect("resolve").expect("some");
let upstream = ctx.upstream();
let state = upstream.as_ref().as_ref().expect("some upstream");
assert_eq!(state.ahead, 0);
assert_eq!(state.behind, 3);
}
#[test]
fn upstream_reports_both_when_diverged() {
let local = TempDir::new().expect("local");
let remote = TempDir::new().expect("remote");
let path = fixture_with_upstream(&local, &remote, 2, 3);
let ctx = resolve_repo(path).expect("resolve").expect("some");
let upstream = ctx.upstream();
let state = upstream.as_ref().as_ref().expect("some upstream");
assert_eq!(state.ahead, 2);
assert_eq!(state.behind, 3);
}
#[test]
fn upstream_is_none_on_detached_head() {
let tmp = TempDir::new().expect("tmp");
let path = fixture_with_commit(&tmp);
run_git(path, &["checkout", "--detach", "HEAD"]);
let ctx = resolve_repo(path).expect("resolve").expect("some");
assert!(matches!(ctx.head, Head::Detached(_)));
assert!(ctx.upstream().is_none());
}
#[test]
fn head_kind_str_covers_every_variant() {
assert_eq!(Head::Branch("x".into()).kind_str(), "branch");
assert_eq!(
Head::Detached(gix::ObjectId::null(gix::hash::Kind::Sha1)).kind_str(),
"detached"
);
assert_eq!(
Head::Unborn {
symbolic_ref: "main".into()
}
.kind_str(),
"unborn"
);
assert_eq!(
Head::OtherRef {
full_name: "refs/remotes/origin/main".into()
}
.kind_str(),
"other_ref"
);
}
}