use std::path::Path;
use anyhow::{Context, Result};
use git2::Repository;
fn open_repo(project: &Path) -> Result<Repository> {
Repository::open(project)
.with_context(|| format!("failed to open repo at {}", project.display()))
}
pub fn has_changes(project: &Path) -> Result<(bool, bool)> {
let repo = open_repo(project)?;
let statuses = repo
.statuses(None)
.with_context(|| format!("failed to get statuses for {}", project.display()))?;
let mut dirty = false;
let mut untracked = false;
for entry in statuses.iter() {
let s = entry.status();
if s.intersects(
git2::Status::WT_MODIFIED
| git2::Status::WT_DELETED
| git2::Status::WT_RENAMED
| git2::Status::WT_TYPECHANGE
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE
| git2::Status::INDEX_NEW,
) {
dirty = true;
}
if s.contains(git2::Status::WT_NEW) {
untracked = true;
}
if dirty && untracked {
break;
}
}
Ok((dirty, untracked))
}
pub fn is_dirty(project: &Path) -> Result<bool> {
Ok(has_changes(project)?.0)
}
pub fn has_untracked(project: &Path) -> Result<bool> {
Ok(has_changes(project)?.1)
}
fn ahead_behind(project: &Path) -> Result<Option<(usize, usize)>> {
let repo = open_repo(project)?;
let head = match repo.head() {
Ok(h) => h,
Err(_) => return Ok(None),
};
let local_oid = match head.target() {
Some(oid) => oid,
None => return Ok(None),
};
let branch_name = match head.shorthand() {
Some(name) => name.to_string(),
None => return Ok(None),
};
let upstream_ref = format!("refs/remotes/origin/{branch_name}");
let upstream_oid = match repo.refname_to_id(&upstream_ref) {
Ok(oid) => oid,
Err(_) => return Ok(None),
};
let counts = repo.graph_ahead_behind(local_oid, upstream_oid)?;
Ok(Some(counts))
}
pub fn non_synchronized(project: &Path) -> Result<bool> {
match ahead_behind(project)? {
Some((ahead, behind)) => Ok(ahead != 0 || behind != 0),
None => Ok(true),
}
}
pub fn is_ahead(project: &Path) -> Result<bool> {
match ahead_behind(project)? {
Some((ahead, _)) => Ok(ahead != 0),
None => Ok(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use git2::Signature;
use std::fs;
use tempfile::TempDir;
fn init_repo_with_commit(dir: &std::path::Path) -> Repository {
let repo = Repository::init(dir).unwrap();
let sig = Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
{
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
}
repo
}
#[test]
fn clean_repo_is_not_dirty() {
let tmp = TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
assert!(!is_dirty(tmp.path()).unwrap());
}
#[test]
fn modified_file_is_dirty() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
let file_path = tmp.path().join("hello.txt");
fs::write(&file_path, "hello").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("hello.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 = Signature::now("Test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "add file", &tree, &[&head])
.unwrap();
fs::write(&file_path, "changed").unwrap();
assert!(is_dirty(tmp.path()).unwrap());
}
#[test]
fn staged_file_is_dirty() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
fs::write(tmp.path().join("new.txt"), "new").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("new.txt")).unwrap();
index.write().unwrap();
assert!(is_dirty(tmp.path()).unwrap());
}
#[test]
fn clean_repo_has_no_untracked() {
let tmp = TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
assert!(!has_untracked(tmp.path()).unwrap());
}
#[test]
fn repo_with_new_file_has_untracked() {
let tmp = TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
fs::write(tmp.path().join("untracked.txt"), "data").unwrap();
assert!(has_untracked(tmp.path()).unwrap());
}
#[test]
fn has_changes_detects_both() {
let tmp = TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
fs::write(tmp.path().join("untracked.txt"), "data").unwrap();
let (dirty, untracked) = has_changes(tmp.path()).unwrap();
assert!(!dirty);
assert!(untracked);
}
#[test]
fn repo_without_upstream_is_non_synchronized() {
let tmp = TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
assert!(non_synchronized(tmp.path()).unwrap());
}
#[test]
fn repo_without_upstream_is_not_ahead() {
let tmp = TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
assert!(!is_ahead(tmp.path()).unwrap());
}
#[test]
fn non_repo_errors() {
let tmp = TempDir::new().unwrap();
assert!(is_dirty(tmp.path()).is_err());
assert!(has_untracked(tmp.path()).is_err());
}
}