use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct GitState {
pub branch: String,
pub commit: String,
pub dirty: bool,
}
pub fn is_git_repo(root: impl AsRef<Path>) -> bool {
root.as_ref().join(".git").exists()
}
pub fn is_git_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| match Command::new("git").arg("--version").output() {
Ok(_) => true,
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
})
}
pub fn get_current_branch(root: impl AsRef<Path>) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(root.as_ref())
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.context("Failed to execute git rev-parse")?;
if !output.status.success() {
anyhow::bail!(
"git rev-parse failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let branch = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in branch name")?
.trim()
.to_string();
Ok(branch)
}
pub fn get_current_commit(root: impl AsRef<Path>) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(root.as_ref())
.args(["rev-parse", "HEAD"])
.output()
.context("Failed to execute git rev-parse HEAD")?;
if !output.status.success() {
anyhow::bail!(
"git rev-parse HEAD failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let commit = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in commit SHA")?
.trim()
.to_string();
Ok(commit)
}
pub fn has_uncommitted_changes(root: impl AsRef<Path>) -> Result<bool> {
let output = Command::new("git")
.arg("-C")
.arg(root.as_ref())
.args(["status", "--porcelain"])
.output()
.context("Failed to execute git status")?;
if !output.status.success() {
anyhow::bail!(
"git status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let has_changes = !output.stdout.is_empty();
Ok(has_changes)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WorktreeChanges {
pub modified: Vec<String>,
pub added: Vec<String>,
pub deleted: Vec<String>,
pub modified_count: usize,
pub added_count: usize,
pub deleted_count: usize,
pub truncated: bool,
}
impl WorktreeChanges {
pub fn is_empty(&self) -> bool {
self.modified_count == 0 && self.added_count == 0 && self.deleted_count == 0
}
pub fn total(&self) -> usize {
self.modified_count + self.added_count + self.deleted_count
}
pub fn any<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
self.modified
.iter()
.chain(&self.added)
.chain(&self.deleted)
.any(|p| pred(p))
}
}
const MAX_REPORTED_PATHS: usize = 100;
pub fn get_worktree_changes<F>(root: impl AsRef<Path>, keep: F) -> Result<WorktreeChanges>
where
F: Fn(&str) -> bool,
{
let output = Command::new("git")
.arg("-C")
.arg(root.as_ref())
.args([
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--no-renames",
"--ignored=no",
])
.output()
.context("Failed to execute git status")?;
if !output.status.success() {
anyhow::bail!(
"git status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let mut changes = WorktreeChanges::default();
for record in output.stdout.split(|b| *b == 0) {
if record.len() < 4 {
continue;
}
let text = String::from_utf8_lossy(record);
let (status, path) = text.split_at(3);
let path = path.trim();
if path.is_empty() || !keep(path) {
continue;
}
let mut bytes = status.bytes();
let x = bytes.next().unwrap_or(b' ');
let y = bytes.next().unwrap_or(b' ');
let (bucket, count) = if x == b'D' || y == b'D' {
(&mut changes.deleted, &mut changes.deleted_count)
} else if x == b'?' || x == b'A' {
(&mut changes.added, &mut changes.added_count)
} else {
(&mut changes.modified, &mut changes.modified_count)
};
*count += 1;
if bucket.len() < MAX_REPORTED_PATHS {
bucket.push(path.to_string());
} else {
changes.truncated = true;
}
}
Ok(changes)
}
pub fn get_git_state(root: impl AsRef<Path>) -> Result<GitState> {
let root = root.as_ref();
if !is_git_repo(root) {
anyhow::bail!("Not a git repository");
}
let branch = get_current_branch(root)?;
let commit = get_current_commit(root)?;
let dirty = has_uncommitted_changes(root)?;
Ok(GitState {
branch,
commit,
dirty,
})
}
pub fn get_git_state_optional(root: impl AsRef<Path>) -> Result<Option<GitState>> {
if !is_git_repo(&root) {
return Ok(None);
}
match get_git_state(root) {
Ok(state) => Ok(Some(state)),
Err(e) => {
log::warn!("Failed to get git state: {}", e);
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_git_repo() {
assert!(is_git_repo("."));
assert!(!is_git_repo("/tmp"));
}
#[test]
fn test_get_current_branch() {
let branch = get_current_branch(".").unwrap();
assert!(!branch.is_empty());
log::info!("Current branch: {}", branch);
}
#[test]
fn test_get_current_commit() {
let commit = get_current_commit(".").unwrap();
assert_eq!(commit.len(), 40);
assert!(commit.chars().all(|c| c.is_ascii_hexdigit()));
log::info!("Current commit: {}", commit);
}
#[test]
fn test_has_uncommitted_changes() {
let has_changes = has_uncommitted_changes(".").unwrap();
log::info!("Has uncommitted changes: {}", has_changes);
}
#[test]
fn test_get_git_state() {
let state = get_git_state(".").unwrap();
assert!(!state.branch.is_empty());
assert_eq!(state.commit.len(), 40);
log::info!("Git state: {:?}", state);
}
}