use anyhow::{Context, Result};
use git2::{DiffOptions, Repository, StatusOptions};
use std::path::Path;
pub async fn get_diff_async(path: Option<String>) -> Result<String> {
tokio::task::spawn_blocking(move || {
get_diff(path.as_deref())
})
.await
.context("Failed to spawn blocking task for git diff")?
}
pub fn get_diff(path: Option<&str>) -> Result<String> {
let repo = Repository::open_from_env()
.context("Failed to open git repository. Is this a git repo?")?;
let mut diff_options = DiffOptions::new();
if let Some(path) = path {
diff_options.pathspec(path);
}
let head = repo.head()?.peel_to_tree()?;
let diff = repo.diff_tree_to_workdir_with_index(Some(&head), Some(&mut diff_options))?;
let mut output = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
output.push_str(std::str::from_utf8(line.content()).unwrap_or("<invalid UTF-8>"));
true
})?;
if output.is_empty() {
output = "No changes detected".to_string();
}
Ok(output)
}
pub fn get_status() -> Result<String> {
let repo = Repository::open_from_env()
.context("Failed to open git repository. Is this a git repo?")?;
let mut status_options = StatusOptions::new();
status_options.include_untracked(true);
status_options.include_ignored(false);
let statuses = repo.statuses(Some(&mut status_options))?;
let mut output = String::new();
output.push_str("Git Status:\n");
output.push_str("-----------\n");
let mut has_changes = false;
for entry in statuses.iter() {
let status = entry.status();
let path = entry.path().unwrap_or("<unknown>");
let status_str = if status.is_wt_new() {
format!(" new file: {}", path)
} else if status.is_wt_modified() {
format!(" modified: {}", path)
} else if status.is_wt_deleted() {
format!(" deleted: {}", path)
} else if status.is_wt_renamed() {
format!(" renamed: {}", path)
} else if status.is_index_new() {
format!(" staged: {}", path)
} else if status.is_index_modified() {
format!(" staged: {}", path)
} else if status.is_index_deleted() {
format!(" staged: {}", path)
} else if status.is_conflicted() {
format!(" conflict: {}", path)
} else {
continue;
};
output.push_str(&status_str);
output.push('\n');
has_changes = true;
}
if !has_changes {
output.push_str(" (working directory clean)\n");
}
if let Ok(head) = repo.head() {
if let Some(name) = head.shorthand() {
output.push_str(&format!("\nOn branch: {}\n", name));
}
}
Ok(output)
}
pub fn commit(message: &str, files: &[String]) -> Result<()> {
let repo = Repository::open_from_env()
.context("Failed to open git repository. Is this a git repo?")?;
let mut index = repo.index()?;
if !files.is_empty() {
for file in files {
index
.add_path(Path::new(file))
.with_context(|| format!("Failed to add file to index: {}", file))?;
}
} else {
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
}
index.write()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let parent_commit = match repo.head() {
Ok(head) => Some(head.peel_to_commit()?),
Err(_) => None, };
let signature = repo
.signature()
.or_else(|_| git2::Signature::now("Mermaid AI", "mermaid@ai.local"))?;
if let Some(parent) = parent_commit.as_ref() {
repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[parent],
)?;
} else {
repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &[])?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_get_status_with_changes() {
let result = get_status();
if result.is_ok() {
let status = result.unwrap();
assert!(
status.contains("Git Status") || status.contains("branch"),
"Status should contain git info"
);
}
}
#[test]
fn test_get_diff_returns_string() {
let result = get_diff(None);
if result.is_ok() {
let diff = result.unwrap();
assert!(
!diff.is_empty() || diff.contains("No changes"),
"Diff should return meaningful output"
);
}
}
#[test]
fn test_commit_requires_git_repo() {
let temp_dir = TempDir::new().unwrap();
let result = Repository::open(temp_dir.path());
assert!(
result.is_err(),
"Opening a repo from a non-git directory should fail"
);
}
#[test]
fn test_status_output_format() {
let result = get_status();
if result.is_ok() {
let status = result.unwrap();
assert!(
status.contains("Git Status")
|| status.contains("clean")
|| status.contains("modified")
|| status.contains("new file"),
"Status should have recognizable format: {}",
status
);
}
}
#[test]
fn test_diff_returns_valid_output() {
let result = get_diff(None);
if let Ok(diff) = result {
assert!(
!diff.is_empty(),
"Diff output should never be empty (returns 'No changes detected' when clean)"
);
}
}
#[test]
fn test_get_status_includes_branch_info() {
let result = get_status();
if result.is_ok() {
let status = result.unwrap();
assert!(
status.contains("On branch")
|| status.contains("Git Status")
|| status.contains("working directory clean"),
"Status should include branch or repo info"
);
}
}
#[test]
fn test_get_diff_with_optional_path() {
let result_all = get_diff(None);
let result_specific = get_diff(Some("Cargo.toml"));
assert!(
result_all.is_ok() || result_all.is_err(),
"get_diff(None) should complete"
);
assert!(
result_specific.is_ok() || result_specific.is_err(),
"get_diff(Some(...)) should complete"
);
}
#[test]
fn test_commit_in_real_repo() {
let result = get_status();
if result.is_ok() {
let status = result.unwrap();
assert!(!status.is_empty(), "Git repo should have status");
}
}
}