use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitInfo {
pub remotes: BTreeMap<String, String>,
pub head_commit: Option<String>,
pub repo_root: Option<String>,
}
pub fn get_git_remote_urls(cwd: &Path) -> Result<BTreeMap<String, String>> {
let output = Command::new("git")
.args(["remote", "-v"])
.current_dir(cwd)
.output()
.with_context(|| format!("Failed to run git remote -v in {}", cwd.display()))?;
if !output.status.success() {
return Ok(BTreeMap::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut remotes = BTreeMap::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let name = parts[0].to_string();
let url = parts[1].to_string();
let purpose = parts[2].trim_matches(|c| c == '(' || c == ')');
if purpose == "fetch" {
remotes.insert(name, url);
}
}
}
Ok(remotes)
}
pub fn get_head_commit_hash(cwd: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(cwd)
.output()
.with_context(|| format!("Failed to run git rev-parse in {}", cwd.display()))?;
if !output.status.success() {
return Ok(None);
}
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
if hash.is_empty() { Ok(None) } else { Ok(Some(hash)) }
}
pub fn get_git_repo_root(cwd: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.with_context(|| format!("Failed to run git rev-parse --show-toplevel in {}", cwd.display()))?;
if !output.status.success() {
return Ok(None);
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() { Ok(None) } else { Ok(Some(root)) }
}
pub fn collect_git_info(cwd: &Path) -> Result<GitInfo> {
let remotes = get_git_remote_urls(cwd)?;
let head_commit = get_head_commit_hash(cwd)?;
let repo_root = get_git_repo_root(cwd)?;
Ok(GitInfo { remotes, head_commit, repo_root })
}
#[must_use]
pub fn is_git_repo(cwd: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
pub async fn get_git_remote_urls_async(cwd: std::path::PathBuf) -> Result<BTreeMap<String, String>> {
tokio::task::spawn_blocking(move || get_git_remote_urls(&cwd))
.await
.context("Git remote URLs task panicked")?
}
pub async fn get_head_commit_hash_async(cwd: std::path::PathBuf) -> Result<Option<String>> {
tokio::task::spawn_blocking(move || get_head_commit_hash(&cwd))
.await
.context("Git HEAD hash task panicked")?
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use vtcode_commons::canonicalize;
#[test]
fn test_is_git_repo() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
assert!(is_git_repo(&repo_root));
}
#[test]
fn test_get_git_repo_root() {
let manifest_dir = canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR"))).unwrap();
let root = get_git_repo_root(&manifest_dir).unwrap();
assert!(root.is_some());
let root = canonicalize(PathBuf::from(root.unwrap())).unwrap();
assert!(
manifest_dir == root || manifest_dir.starts_with(&root),
"repo root {} should be an ancestor of manifest dir {}",
root.display(),
manifest_dir.display()
);
}
#[test]
fn test_get_head_commit_hash() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let hash = get_head_commit_hash(&repo_root).unwrap();
assert!(hash.is_some());
let hash_str = hash.unwrap();
assert!(hash_str.len() >= 7 && hash_str.len() <= 12);
assert!(hash_str.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_collect_git_info() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let info = collect_git_info(&repo_root).unwrap();
assert!(info.head_commit.is_some());
assert!(info.repo_root.is_some());
}
#[test]
fn test_non_git_directory() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let non_git_path = temp_dir.path().join("not_a_repo");
fs::create_dir(&non_git_path).unwrap();
assert!(!is_git_repo(&non_git_path));
assert!(get_git_remote_urls(&non_git_path).unwrap().is_empty());
assert!(get_head_commit_hash(&non_git_path).unwrap().is_none());
assert!(get_git_repo_root(&non_git_path).unwrap().is_none());
}
}