use crate::config::SubmoduleConfig;
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 has_unpushed_submodules: bool,
pub fetch_failed: bool,
pub stash_count: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct FileEntry {
pub path: PathBuf,
pub status: FileStatus,
pub is_submodule: bool,
pub submodule_state: Option<SubmoduleState>,
pub submodule_warn: SubmoduleWarn,
}
#[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, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct SubmoduleWarn {
pub unpushed_commits: usize,
pub pointer_unreachable: bool,
}
impl SubmoduleWarn {
pub fn is_clean(&self) -> bool {
self.unpushed_commits == 0 && !self.pointer_unreachable
}
}
#[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: Option<SubmoduleState>,
pub head_oid: Option<String>,
pub workdir_oid: Option<String>,
pub warn: SubmoduleWarn,
}
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,
sub_cfg: &SubmoduleConfig,
) -> color_eyre::Result<RepoStatus> {
query_status_inner(path, false, sub_cfg)
}
pub(crate) fn query_status_with_fetch(
path: &Path,
sub_cfg: &SubmoduleConfig,
) -> color_eyre::Result<RepoStatus> {
query_status_inner(path, true, sub_cfg)
}
fn query_status_inner(
path: &Path,
fetch: bool,
sub_cfg: &SubmoduleConfig,
) -> color_eyre::Result<RepoStatus> {
let mut repo = Repository::open(path)?;
let mut stash_count = 0usize;
let _ = repo.stash_foreach(|_, _, _| {
stash_count += 1;
true
});
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 sub_cfg.ignore_dirty {
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,
submodule_warn: SubmoduleWarn::default(),
});
}
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;
let mut has_unpushed_submodules = false;
if has_submodules
&& (!sub_cfg.ignore_dirty || sub_cfg.warn_unpushed)
&& 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 state = if sub_cfg.ignore_dirty {
None
} else {
let status = repo
.submodule_status(&name, git2::SubmoduleIgnore::Unspecified)
.unwrap_or(SubmoduleStatus::empty());
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
}
};
let warn = if sub_cfg.warn_unpushed && state != Some(SubmoduleState::Uninitialized) {
compute_submodule_warn(sub)
} else {
SubmoduleWarn::default()
};
let has_dirty_signal = state.is_some();
let has_warn_signal = !warn.is_clean();
if !has_dirty_signal && !has_warn_signal {
continue;
}
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,
warn,
});
if let Some(file_entry) = files.iter_mut().find(|f| f.path == sub_path) {
file_entry.is_submodule = true;
file_entry.submodule_state = state.clone();
file_entry.submodule_warn = warn;
} else {
files.push(FileEntry {
path: sub_path,
status: FileStatus::Modified,
is_submodule: true,
submodule_state: state,
submodule_warn: warn,
});
}
if has_dirty_signal {
has_dirty_submodules = true;
}
if has_warn_signal {
has_unpushed_submodules = true;
}
}
}
Ok(RepoStatus {
branch,
files,
ahead,
behind,
is_dirty: is_dirty || has_dirty_submodules,
worktree_info,
has_submodules,
submodules,
has_dirty_submodules,
has_unpushed_submodules,
fetch_failed,
stash_count,
})
}
fn compute_submodule_warn(sub: &git2::Submodule) -> SubmoduleWarn {
let recorded = match sub.index_id() {
Some(o) => o,
None => return SubmoduleWarn::default(),
};
let inner = match sub.open() {
Ok(r) => r,
Err(_) => return SubmoduleWarn::default(),
};
let unpushed_commits = compute_ahead_behind(&inner).0;
if inner.find_object(recorded, None).is_err() {
return SubmoduleWarn {
unpushed_commits,
pointer_unreachable: true,
};
}
if let Ok(branches) = inner.branches(Some(git2::BranchType::Remote)) {
for (b, _) in branches.flatten() {
if let Some(tip) = b.get().target()
&& (tip == recorded || inner.graph_descendant_of(tip, recorded).unwrap_or(false))
{
return SubmoduleWarn {
unpushed_commits,
pointer_unreachable: false,
};
}
}
}
SubmoduleWarn {
unpushed_commits,
pointer_unreachable: true,
}
}
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),
};
if let Ok(upstream) = branch.upstream()
&& let Some(upstream_oid) = upstream.get().target()
{
return repo
.graph_ahead_behind(local_oid, upstream_oid)
.unwrap_or((0, 0));
}
ahead_against_remote_tips(repo, local_oid)
}
fn ahead_against_remote_tips(repo: &Repository, local_oid: git2::Oid) -> (usize, usize) {
let mut walk = match repo.revwalk() {
Ok(w) => w,
Err(_) => return (0, 0),
};
if walk.push(local_oid).is_err() {
return (0, 0);
}
let mut any_remote = false;
if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
for (branch, _) in branches.flatten() {
if let Some(tip) = branch.get().target() {
any_remote = true;
let _ = walk.hide(tip);
}
}
}
if !any_remote {
return (0, 0);
}
let count = walk.filter_map(Result::ok).count();
(count, 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn commit_index(repo: &Repository, message: &str) -> git2::Oid {
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let mut index = repo.index().unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let head = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit<'_>> = head.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
.unwrap()
}
fn init_temp_repo() -> (TempDir, Repository) {
let tmp = TempDir::new().unwrap();
let repo = Repository::init(tmp.path()).unwrap();
commit_index(&repo, "Initial commit");
(tmp, repo)
}
#[test]
fn test_clean_repo_reports_no_changes() {
let (tmp, _repo) = init_temp_repo();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).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(), &SubmoduleConfig::default()).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(), &SubmoduleConfig::default()).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(), &SubmoduleConfig::default()).unwrap();
assert!(status.worktree_info.is_empty());
}
#[test]
fn test_worktree_info_reflects_linked_worktrees() {
let (tmp, repo) = init_temp_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
let branch = repo.branch("wt-branch", &head, false).unwrap();
let reference = branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
let wt_dir = tmp.path().join("wt1");
repo.worktree("wt1", &wt_dir, Some(&opts)).unwrap();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).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(), &SubmoduleConfig::default()).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),
submodule_warn: SubmoduleWarn::default(),
};
assert!(entry.is_submodule);
assert_eq!(entry.submodule_state, Some(SubmoduleState::Modified));
assert!(entry.submodule_warn.is_clean());
let plain = FileEntry {
path: PathBuf::from("src/main.rs"),
status: FileStatus::Modified,
is_submodule: false,
submodule_state: None,
submodule_warn: SubmoduleWarn::default(),
};
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(),
&SubmoduleConfig {
ignore_dirty: true,
warn_unpushed: false,
},
)
.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(),
&SubmoduleConfig {
ignore_dirty: true,
warn_unpushed: false,
},
)
.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 mut submodule = repo
.submodule(
sub_source.path().to_str().unwrap(),
Path::new("my-sub"),
true,
)
.unwrap();
submodule.clone(None).unwrap();
submodule.add_finalize().unwrap();
commit_index(&repo, "add submodule");
(tmp, sub_source, sub_repo)
}
fn stage_submodule_pointer(parent_path: &Path, sub_dirname: &str) {
let repo = Repository::open(parent_path).unwrap();
let mut submodule = repo.find_submodule(sub_dirname).unwrap();
submodule.reload(true).unwrap();
submodule.add_to_index(true).unwrap();
}
#[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(), &SubmoduleConfig::default()).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(), &SubmoduleConfig::default()).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, Some(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(),
&SubmoduleConfig {
ignore_dirty: true,
warn_unpushed: 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_submodule_modified_pointer() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
add_unpushed_commit_in_sub(tmp.path(), "my-sub");
let status = query_status(tmp.path(), &SubmoduleConfig::default()).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 == Some(SubmoduleState::Modified)
|| sub_info.state == Some(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(), &SubmoduleConfig::default()).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(), &SubmoduleConfig::default()).unwrap();
assert!(!status.is_dirty);
fs::write(tmp.path().join("my-sub/lib.rs"), "dirty").unwrap();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(status.is_dirty);
}
#[test]
fn test_submodule_warn_default_is_clean() {
let warn = SubmoduleWarn::default();
assert!(warn.is_clean());
assert_eq!(warn.unpushed_commits, 0);
assert!(!warn.pointer_unreachable);
}
#[test]
fn test_submodule_warn_is_clean_predicate() {
assert!(SubmoduleWarn::default().is_clean());
assert!(
!SubmoduleWarn {
unpushed_commits: 1,
pointer_unreachable: false,
}
.is_clean()
);
assert!(
!SubmoduleWarn {
unpushed_commits: 0,
pointer_unreachable: true,
}
.is_clean()
);
}
#[test]
fn test_clean_submodule_warn_fields_clean() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(!status.has_unpushed_submodules);
}
#[test]
fn test_dirty_submodule_warn_fields_stay_clean() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
fs::write(tmp.path().join("my-sub/lib.rs"), "dirty").unwrap();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(status.has_dirty_submodules);
assert!(!status.has_unpushed_submodules);
let sub_info = &status.submodules[0];
assert!(sub_info.warn.is_clean());
let file_entry = status
.files
.iter()
.find(|f| f.path == Path::new("my-sub"))
.unwrap();
assert!(file_entry.submodule_warn.is_clean());
}
#[test]
fn test_warn_unpushed_false_zeros_warn_fields_even_when_dirty() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
fs::write(tmp.path().join("my-sub/lib.rs"), "dirty").unwrap();
let cfg = SubmoduleConfig {
ignore_dirty: false,
warn_unpushed: false,
};
let status = query_status(tmp.path(), &cfg).unwrap();
assert!(status.has_dirty_submodules);
assert!(!status.has_unpushed_submodules);
assert!(status.submodules[0].warn.is_clean());
}
#[test]
fn test_ignore_dirty_with_warn_unpushed_iterates_subs() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
fs::write(tmp.path().join("my-sub/lib.rs"), "dirty").unwrap();
let cfg = SubmoduleConfig {
ignore_dirty: true,
warn_unpushed: true,
};
let status = query_status(tmp.path(), &cfg).unwrap();
assert!(!status.has_dirty_submodules);
assert!(!status.has_unpushed_submodules);
assert!(status.submodules.is_empty());
}
#[test]
fn test_both_flags_off_skips_submodule_loop_entirely() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
fs::write(tmp.path().join("my-sub/lib.rs"), "dirty").unwrap();
let cfg = SubmoduleConfig {
ignore_dirty: true,
warn_unpushed: false,
};
let status = query_status(tmp.path(), &cfg).unwrap();
assert!(status.has_submodules); assert!(!status.has_dirty_submodules);
assert!(!status.has_unpushed_submodules);
assert!(status.submodules.is_empty());
}
#[test]
fn test_uninitialized_submodule_does_not_panic_on_warn_check() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
fs::remove_dir_all(tmp.path().join("my-sub")).unwrap();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(status.has_submodules);
for sub in &status.submodules {
assert!(sub.warn.is_clean());
}
assert!(!status.has_unpushed_submodules);
}
fn add_unpushed_commit_in_sub(parent_path: &Path, sub_dirname: &str) -> git2::Oid {
let sub_repo = Repository::open(parent_path.join(sub_dirname)).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let head_commit = sub_repo.head().unwrap().peel_to_commit().unwrap();
fs::write(
parent_path.join(sub_dirname).join("extra.rs"),
"fn extra() {}",
)
.unwrap();
let mut idx = sub_repo.index().unwrap();
idx.add_path(Path::new("extra.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,
"unpushed work",
&tree,
&[&head_commit],
)
.unwrap()
}
#[test]
fn test_parent_pinning_unpushed_oid_marks_pointer_unreachable() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let unpushed = add_unpushed_commit_in_sub(tmp.path(), "my-sub");
stage_submodule_pointer(tmp.path(), "my-sub");
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(status.has_unpushed_submodules);
let sub_info = status
.submodules
.iter()
.find(|s| s.path == Path::new("my-sub"))
.expect("submodule entry should exist with warn signal");
assert!(
sub_info.warn.pointer_unreachable,
"expected pointer_unreachable=true for staged oid {}, got {:?}",
unpushed, sub_info.warn
);
let file_entry = status
.files
.iter()
.find(|f| f.path == Path::new("my-sub"))
.expect("file entry for my-sub");
assert!(file_entry.is_submodule);
assert!(file_entry.submodule_warn.pointer_unreachable);
}
#[test]
fn test_warn_unpushed_false_zeros_unreachable_pointer() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
add_unpushed_commit_in_sub(tmp.path(), "my-sub");
stage_submodule_pointer(tmp.path(), "my-sub");
let cfg = SubmoduleConfig {
ignore_dirty: false,
warn_unpushed: false,
};
let status = query_status(tmp.path(), &cfg).unwrap();
assert!(!status.has_unpushed_submodules);
for sub in &status.submodules {
assert!(
sub.warn.is_clean(),
"warn fields must be zero when warn_unpushed=false, got {:?}",
sub.warn
);
}
}
#[test]
fn test_unpushed_commits_count_when_branch_ahead_of_upstream() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let sub_dir = tmp.path().join("my-sub");
let inner = Repository::open(&sub_dir).unwrap();
let mut remote_branch: Option<String> = None;
if let Ok(branches) = inner.branches(Some(git2::BranchType::Remote)) {
for (b, _) in branches.flatten() {
if let Ok(Some(name)) = b.name()
&& let Some(stripped) = name.strip_prefix("origin/")
&& stripped != "HEAD"
{
remote_branch = Some(stripped.to_string());
break;
}
}
}
let branch = remote_branch.expect("submodule should have an origin/<branch> ref");
let remote_ref = inner
.find_reference(&format!("refs/remotes/origin/{}", branch))
.unwrap();
let remote_oid = remote_ref.target().unwrap();
let remote_commit = inner.find_commit(remote_oid).unwrap();
let mut local_branch = inner
.find_branch(&branch, git2::BranchType::Local)
.or_else(|_| inner.branch(&branch, &remote_commit, false))
.unwrap();
local_branch
.set_upstream(Some(&format!("origin/{}", branch)))
.unwrap();
inner.set_head(&format!("refs/heads/{}", branch)).unwrap();
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force();
inner.checkout_head(Some(&mut checkout)).unwrap();
add_unpushed_commit_in_sub(tmp.path(), "my-sub");
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(status.has_unpushed_submodules);
let sub_info = status
.submodules
.iter()
.find(|s| s.path == Path::new("my-sub"))
.expect("submodule entry expected");
assert_eq!(
sub_info.warn.unpushed_commits, 1,
"expected 1 unpushed commit, got {:?}",
sub_info.warn
);
assert!(
!sub_info.warn.pointer_unreachable,
"parent's pointer is unchanged and on origin — should be reachable"
);
}
#[test]
fn test_detached_head_at_remote_oid_no_warn() {
let (tmp, _sub_source, _sub_repo) = init_repo_with_submodule();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert!(!status.has_unpushed_submodules);
for sub in &status.submodules {
assert!(sub.warn.is_clean());
}
}
fn add_commit_on_head(repo: &Repository, file: &str, contents: &str) -> git2::Oid {
let workdir = repo.workdir().unwrap().to_path_buf();
fs::write(workdir.join(file), contents).unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new(file)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("Add {}", file),
&tree,
&[&parent],
)
.unwrap()
}
#[test]
fn test_ahead_count_when_no_upstream_with_remote_ref() {
let (tmp, repo) = init_temp_repo();
let a = repo.head().unwrap().target().unwrap();
repo.reference("refs/remotes/origin/main", a, true, "test setup")
.unwrap();
add_commit_on_head(&repo, "b.txt", "b");
add_commit_on_head(&repo, "c.txt", "c");
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert_eq!(status.ahead, 2);
assert_eq!(status.behind, 0);
}
#[test]
fn test_no_remotes_keeps_zero_ahead() {
let (tmp, repo) = init_temp_repo();
add_commit_on_head(&repo, "b.txt", "b");
add_commit_on_head(&repo, "c.txt", "c");
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert_eq!(status.ahead, 0);
assert_eq!(status.behind, 0);
}
#[test]
fn test_stash_count_zero_when_no_stash() {
let (tmp, _repo) = init_temp_repo();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert_eq!(status.stash_count, 0);
}
#[test]
fn test_stash_count_reflects_stash_save() {
let (tmp, mut repo) = init_temp_repo();
add_commit_on_head(&repo, "tracked.txt", "v1");
fs::write(tmp.path().join("tracked.txt"), "v2").unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
repo.stash_save2(&sig, None, None).unwrap();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert_eq!(status.stash_count, 1);
fs::write(tmp.path().join("tracked.txt"), "v3").unwrap();
repo.stash_save2(&sig, None, None).unwrap();
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert_eq!(status.stash_count, 2);
}
#[test]
fn test_ahead_count_when_head_shares_only_root_with_unrelated_remote() {
let (tmp, repo) = init_temp_repo();
let a = repo.head().unwrap().target().unwrap();
let a_commit = repo.find_commit(a).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let workdir = repo.workdir().unwrap().to_path_buf();
fs::write(workdir.join("d.txt"), "d").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("d.txt")).unwrap();
let d_tree_id = index.write_tree().unwrap();
let d_tree = repo.find_tree(d_tree_id).unwrap();
let d = repo
.commit(None, &sig, &sig, "D", &d_tree, &[&a_commit])
.unwrap();
index.remove_path(Path::new("d.txt")).unwrap();
index.write().unwrap();
fs::remove_file(workdir.join("d.txt")).unwrap();
repo.reference("refs/remotes/origin/other", d, true, "test setup")
.unwrap();
add_commit_on_head(&repo, "b.txt", "b");
add_commit_on_head(&repo, "c.txt", "c");
let status = query_status(tmp.path(), &SubmoduleConfig::default()).unwrap();
assert_eq!(status.ahead, 2);
assert_eq!(status.behind, 0);
}
}