use git2::{Repository, StatusOptions, SubmoduleStatus};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub(crate) struct RepoStatus {
pub branch: String,
pub files: Vec<FileEntry>,
pub ahead: usize,
pub behind: usize,
pub is_dirty: bool,
pub worktree_info: Vec<WorktreeEntry>,
pub has_submodules: bool,
pub submodules: Vec<SubmoduleInfo>,
pub has_dirty_submodules: bool,
pub fetch_failed: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct FileEntry {
pub path: PathBuf,
pub status: FileStatus,
pub is_submodule: bool,
pub submodule_state: Option<SubmoduleState>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum FileStatus {
Modified,
Added,
Deleted,
Renamed,
Untracked,
Conflicted,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SubmoduleState {
Modified,
Uninitialized,
Dirty,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub(crate) struct WorktreeEntry {
pub name: String,
pub path: PathBuf,
pub branch: String,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub(crate) struct SubmoduleInfo {
pub name: String,
pub path: PathBuf,
pub state: SubmoduleState,
pub head_oid: Option<String>,
pub workdir_oid: Option<String>,
}
impl FileStatus {
pub fn label(&self) -> &'static str {
match self {
Self::Modified => "M",
Self::Added => "A",
Self::Deleted => "D",
Self::Renamed => "R",
Self::Untracked => "?",
Self::Conflicted => "C",
}
}
}
pub(crate) fn query_status(path: &Path, ignore_dirty_subs: bool) -> color_eyre::Result<RepoStatus> {
query_status_inner(path, false, ignore_dirty_subs)
}
pub(crate) fn query_status_with_fetch(
path: &Path,
ignore_dirty_subs: bool,
) -> color_eyre::Result<RepoStatus> {
query_status_inner(path, true, ignore_dirty_subs)
}
fn query_status_inner(
path: &Path,
fetch: bool,
ignore_dirty_subs: bool,
) -> color_eyre::Result<RepoStatus> {
let repo = Repository::open(path)?;
let branch = match repo.head() {
Ok(reference) => reference.shorthand().unwrap_or("HEAD").to_string(),
Err(_) => "(no branch)".to_string(),
};
let fetch_failed = if fetch {
!fetch_remote_silent(path)
} else {
false
};
let (ahead, behind) = compute_ahead_behind(&repo);
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.renames_head_to_index(true);
if ignore_dirty_subs {
opts.exclude_submodules(true);
}
let statuses = repo.statuses(Some(&mut opts))?;
let mut files = Vec::new();
for entry in statuses.iter() {
let s = entry.status();
let file_path = PathBuf::from(entry.path().unwrap_or(""));
let file_status = if s.is_conflicted() {
FileStatus::Conflicted
} else if s.is_index_new() || s.is_wt_new() {
if s.is_wt_new() && !s.is_index_new() {
FileStatus::Untracked
} else {
FileStatus::Added
}
} else if s.is_index_deleted() || s.is_wt_deleted() {
FileStatus::Deleted
} else if s.is_index_renamed() || s.is_wt_renamed() {
FileStatus::Renamed
} else if s.is_index_modified() || s.is_wt_modified() {
FileStatus::Modified
} else {
continue;
};
files.push(FileEntry {
path: file_path,
status: file_status,
is_submodule: false,
submodule_state: None,
});
}
let is_dirty = !files.is_empty();
let worktree_info = collect_worktree_info(&repo);
let has_submodules = path.join(".gitmodules").is_file();
let mut submodules = Vec::new();
let mut has_dirty_submodules = false;
if has_submodules
&& !ignore_dirty_subs
&& let Ok(subs) = repo.submodules()
{
for sub in &subs {
let name = sub.name().unwrap_or("").to_string();
let sub_path = PathBuf::from(sub.path());
let status = repo
.submodule_status(&name, git2::SubmoduleIgnore::Unspecified)
.unwrap_or(SubmoduleStatus::empty());
let state = if status.is_wd_uninitialized() {
Some(SubmoduleState::Uninitialized)
} else if status.is_wd_wd_modified() || status.contains(SubmoduleStatus::WD_UNTRACKED) {
Some(SubmoduleState::Dirty)
} else if status.is_wd_modified() || status.contains(SubmoduleStatus::WD_INDEX_MODIFIED)
{
Some(SubmoduleState::Modified)
} else {
None
};
if let Some(state) = state {
let head_oid = sub.head_id().map(|id| id.to_string());
let workdir_oid = sub.workdir_id().map(|id| id.to_string());
submodules.push(SubmoduleInfo {
name: name.clone(),
path: sub_path.clone(),
state: state.clone(),
head_oid,
workdir_oid,
});
if let Some(file_entry) = files.iter_mut().find(|f| f.path == sub_path) {
file_entry.is_submodule = true;
file_entry.submodule_state = Some(state.clone());
} else {
files.push(FileEntry {
path: sub_path,
status: FileStatus::Modified,
is_submodule: true,
submodule_state: Some(state),
});
}
has_dirty_submodules = true;
}
}
}
Ok(RepoStatus {
branch,
files,
ahead,
behind,
is_dirty: is_dirty || has_dirty_submodules,
worktree_info,
has_submodules,
submodules,
has_dirty_submodules,
fetch_failed,
})
}
fn collect_worktree_info(repo: &Repository) -> Vec<WorktreeEntry> {
let wt_names = match repo.worktrees() {
Ok(names) => names,
Err(_) => return Vec::new(),
};
let mut entries = Vec::new();
for i in 0..wt_names.len() {
let name = match wt_names.get(i) {
Some(n) => n,
None => continue,
};
let wt = match repo.find_worktree(name) {
Ok(wt) => wt,
Err(_) => continue,
};
let wt_path = wt.path().to_path_buf();
let branch = match Repository::open(&wt_path) {
Ok(wt_repo) => match wt_repo.head() {
Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(),
Err(_) => "(no branch)".to_string(),
},
Err(_) => continue,
};
entries.push(WorktreeEntry {
name: name.to_string(),
path: wt_path,
branch,
});
}
entries
}
fn fetch_remote_silent(path: &Path) -> bool {
use wait_timeout::ChildExt;
let child = std::process::Command::new("git")
.arg("-C")
.arg(path)
.arg("fetch")
.arg("--quiet")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match child {
Ok(mut c) => {
match c.wait_timeout(std::time::Duration::from_secs(30)) {
Ok(Some(status)) => status.success(),
Ok(None) => {
let _ = c.kill();
let _ = c.wait();
false
}
Err(_) => false,
}
}
Err(_) => false,
}
}
fn compute_ahead_behind(repo: &Repository) -> (usize, usize) {
let head = match repo.head() {
Ok(h) => h,
Err(_) => return (0, 0),
};
let local_oid = match head.target() {
Some(oid) => oid,
None => return (0, 0),
};
let branch_name = match head.shorthand() {
Some(name) => name.to_string(),
None => return (0, 0),
};
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return (0, 0),
};
let upstream = match branch.upstream() {
Ok(u) => u,
Err(_) => return (0, 0),
};
let upstream_oid = match upstream.get().target() {
Some(oid) => oid,
None => return (0, 0),
};
repo.graph_ahead_behind(local_oid, upstream_oid)
.unwrap_or((0, 0))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn init_temp_repo() -> (TempDir, Repository) {
let tmp = TempDir::new().unwrap();
let repo = Repository::init(tmp.path()).unwrap();
{
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
}
(tmp, repo)
}
#[test]
fn test_clean_repo_reports_no_changes() {
let (tmp, _repo) = init_temp_repo();
let status = query_status(tmp.path(), false).unwrap();
assert!(!status.is_dirty);
assert!(status.files.is_empty());
}
#[test]
fn test_modified_file_detected() {
let (tmp, repo) = init_temp_repo();
let file_path = tmp.path().join("test.txt");
fs::write(&file_path, "hello").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("test.txt")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[&head])
.unwrap();
fs::write(&file_path, "world").unwrap();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.is_dirty);
assert!(
status
.files
.iter()
.any(|f| f.status == FileStatus::Modified)
);
}
#[test]
fn test_untracked_file_detected() {
let (tmp, _repo) = init_temp_repo();
fs::write(tmp.path().join("new.txt"), "new").unwrap();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.is_dirty);
assert!(
status
.files
.iter()
.any(|f| f.status == FileStatus::Untracked)
);
}
#[test]
fn test_worktree_info_empty_for_plain_repo() {
let (tmp, _repo) = init_temp_repo();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.worktree_info.is_empty());
}
#[test]
fn test_worktree_info_reflects_linked_worktrees() {
let (tmp, _repo) = init_temp_repo();
let wt_dir = tmp.path().join("wt1");
let output = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.arg("worktree")
.arg("add")
.arg(&wt_dir)
.arg("-b")
.arg("wt-branch")
.output()
.unwrap();
assert!(output.status.success(), "git worktree add failed");
let status = query_status(tmp.path(), false).unwrap();
assert_eq!(status.worktree_info.len(), 1);
assert_eq!(status.worktree_info[0].branch, "wt-branch");
assert_eq!(status.worktree_info[0].name, "wt1");
}
#[test]
fn test_submodule_state_mapping() {
let flags = SubmoduleStatus::WD_UNINITIALIZED;
assert!(flags.is_wd_uninitialized());
let flags = SubmoduleStatus::WD_WD_MODIFIED;
assert!(flags.is_wd_wd_modified());
let flags = SubmoduleStatus::WD_UNTRACKED;
assert!(flags.contains(SubmoduleStatus::WD_UNTRACKED));
let flags = SubmoduleStatus::WD_MODIFIED;
assert!(flags.is_wd_modified());
let flags = SubmoduleStatus::WD_INDEX_MODIFIED;
assert!(flags.contains(SubmoduleStatus::WD_INDEX_MODIFIED));
}
#[test]
fn test_clean_repo_no_dirty_submodules() {
let (tmp, _repo) = init_temp_repo();
let status = query_status(tmp.path(), false).unwrap();
assert!(!status.has_dirty_submodules);
assert!(status.submodules.is_empty());
}
#[test]
fn test_status_maps_correctly() {
assert_eq!(FileStatus::Modified.label(), "M");
assert_eq!(FileStatus::Added.label(), "A");
assert_eq!(FileStatus::Deleted.label(), "D");
assert_eq!(FileStatus::Renamed.label(), "R");
assert_eq!(FileStatus::Untracked.label(), "?");
assert_eq!(FileStatus::Conflicted.label(), "C");
}
#[test]
fn test_file_entry_submodule_fields() {
let entry = FileEntry {
path: PathBuf::from("my-submodule"),
status: FileStatus::Modified,
is_submodule: true,
submodule_state: Some(SubmoduleState::Modified),
};
assert!(entry.is_submodule);
assert_eq!(entry.submodule_state, Some(SubmoduleState::Modified));
let plain = FileEntry {
path: PathBuf::from("src/main.rs"),
status: FileStatus::Modified,
is_submodule: false,
submodule_state: None,
};
assert!(!plain.is_submodule);
assert_eq!(plain.submodule_state, None);
}
#[test]
fn test_submodule_state_equality_and_clone() {
assert_eq!(SubmoduleState::Modified, SubmoduleState::Modified);
assert_eq!(SubmoduleState::Dirty, SubmoduleState::Dirty);
assert_eq!(SubmoduleState::Uninitialized, SubmoduleState::Uninitialized);
assert_ne!(SubmoduleState::Modified, SubmoduleState::Dirty);
assert_ne!(SubmoduleState::Dirty, SubmoduleState::Uninitialized);
let state = SubmoduleState::Modified;
let cloned = state.clone();
assert_eq!(state, cloned);
}
#[test]
fn test_ignore_dirty_subs_on_clean_repo() {
let (tmp, _repo) = init_temp_repo();
let status = query_status(tmp.path(), true).unwrap();
assert!(!status.is_dirty);
assert!(status.files.is_empty());
assert!(status.submodules.is_empty());
assert!(!status.has_dirty_submodules);
}
#[test]
fn test_ignore_dirty_subs_still_detects_regular_changes() {
let (tmp, _repo) = init_temp_repo();
fs::write(tmp.path().join("new.txt"), "new").unwrap();
let status = query_status(tmp.path(), true).unwrap();
assert!(status.is_dirty);
assert!(
status
.files
.iter()
.any(|f| f.status == FileStatus::Untracked)
);
assert!(status.submodules.is_empty());
assert!(!status.has_dirty_submodules);
}
#[test]
fn test_submodule_state_priority_uninitialized_first() {
let flags = SubmoduleStatus::WD_UNINITIALIZED | SubmoduleStatus::WD_MODIFIED;
assert!(flags.is_wd_uninitialized());
}
#[test]
fn test_submodule_state_priority_dirty_over_modified() {
let flags = SubmoduleStatus::WD_WD_MODIFIED | SubmoduleStatus::WD_MODIFIED;
assert!(flags.is_wd_wd_modified());
assert!(flags.is_wd_modified());
}
fn init_repo_with_submodule() -> (TempDir, TempDir, Repository) {
let (tmp, _repo) = init_temp_repo();
let sub_source = TempDir::new().unwrap();
let sub_repo = Repository::init(sub_source.path()).unwrap();
{
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
fs::write(sub_source.path().join("lib.rs"), "fn hello() {}").unwrap();
let mut idx = sub_repo.index().unwrap();
idx.add_path(Path::new("lib.rs")).unwrap();
idx.write().unwrap();
let tree_id = idx.write_tree().unwrap();
let tree = sub_repo.find_tree(tree_id).unwrap();
sub_repo
.commit(Some("HEAD"), &sig, &sig, "init sub", &tree, &[])
.unwrap();
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args([
"-c",
"protocol.file.allow=always",
"submodule",
"add",
sub_source.path().to_str().unwrap(),
"my-sub",
])
.output()
.unwrap();
assert!(
output.status.success(),
"git submodule add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args(["add", "."])
.output()
.unwrap();
assert!(output.status.success());
let output = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@test.com",
"commit",
"-m",
"add submodule",
])
.output()
.unwrap();
assert!(
output.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
(tmp, sub_source, sub_repo)
}
#[test]
fn test_dirty_submodule_with_real_git_submodule() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.has_submodules);
assert!(!status.has_dirty_submodules);
assert!(status.submodules.is_empty());
let sub_workdir = tmp.path().join("my-sub");
fs::write(sub_workdir.join("lib.rs"), "fn hello() { /* changed */ }").unwrap();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.has_submodules);
assert!(status.has_dirty_submodules);
assert!(!status.submodules.is_empty());
let sub_info = &status.submodules[0];
assert_eq!(sub_info.path, Path::new("my-sub"));
assert_eq!(sub_info.state, SubmoduleState::Dirty);
let file_entry = status.files.iter().find(|f| f.path == Path::new("my-sub"));
assert!(file_entry.is_some());
let file_entry = file_entry.unwrap();
assert!(file_entry.is_submodule);
assert_eq!(file_entry.submodule_state, Some(SubmoduleState::Dirty));
}
#[test]
fn test_ignore_dirty_subs_hides_submodule_state() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
fs::write(tmp.path().join("my-sub/lib.rs"), "fn changed() {}").unwrap();
let status = query_status(tmp.path(), true).unwrap();
assert!(status.has_submodules); assert!(!status.has_dirty_submodules);
assert!(status.submodules.is_empty());
assert!(!status.files.iter().any(|f| f.is_submodule));
}
#[test]
fn test_submodule_modified_pointer() {
let (tmp, _sub_source, sub_repo) = init_repo_with_submodule();
{
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
fs::write(_sub_source.path().join("lib.rs"), "v2").unwrap();
let mut idx = sub_repo.index().unwrap();
idx.add_path(Path::new("lib.rs")).unwrap();
idx.write().unwrap();
let tree_id = idx.write_tree().unwrap();
let tree = sub_repo.find_tree(tree_id).unwrap();
let head = sub_repo.head().unwrap().peel_to_commit().unwrap();
sub_repo
.commit(Some("HEAD"), &sig, &sig, "v2", &tree, &[&head])
.unwrap();
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path().join("my-sub"))
.args([
"-c",
"protocol.file.allow=always",
"pull",
"origin",
"master",
])
.output()
.unwrap();
if !output.status.success() {
let _ = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path().join("my-sub"))
.args(["-c", "protocol.file.allow=always", "pull", "origin", "main"])
.output();
}
let status = query_status(tmp.path(), false).unwrap();
assert!(status.has_submodules);
assert!(status.has_dirty_submodules);
assert!(!status.submodules.is_empty());
let sub_info = &status.submodules[0];
assert_eq!(sub_info.path, Path::new("my-sub"));
assert!(
sub_info.state == SubmoduleState::Modified || sub_info.state == SubmoduleState::Dirty,
"expected Modified or Dirty, got {:?}",
sub_info.state
);
assert!(sub_info.head_oid.is_some());
assert!(sub_info.workdir_oid.is_some());
assert_ne!(sub_info.head_oid, sub_info.workdir_oid);
}
#[test]
fn test_clean_submodule_not_reported() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.has_submodules);
assert!(!status.has_dirty_submodules);
assert!(status.submodules.is_empty());
assert!(!status.files.iter().any(|f| f.is_submodule));
}
#[test]
fn test_dirty_submodule_makes_repo_dirty() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let status = query_status(tmp.path(), false).unwrap();
assert!(!status.is_dirty);
fs::write(tmp.path().join("my-sub/lib.rs"), "dirty").unwrap();
let status = query_status(tmp.path(), false).unwrap();
assert!(status.is_dirty);
}
}