1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::Path;
11use std::process::Command;
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct GitInfo {
16 pub remotes: BTreeMap<String, String>,
18 pub head_commit: Option<String>,
20 pub repo_root: Option<String>,
22}
23
24pub fn get_git_remote_urls(cwd: &Path) -> Result<BTreeMap<String, String>> {
34 let output = Command::new("git")
35 .args(["remote", "-v"])
36 .current_dir(cwd)
37 .output()
38 .with_context(|| format!("Failed to run git remote -v in {}", cwd.display()))?;
39
40 if !output.status.success() {
41 return Ok(BTreeMap::new());
43 }
44
45 let stdout = String::from_utf8_lossy(&output.stdout);
46 let mut remotes = BTreeMap::new();
47
48 for line in stdout.lines() {
52 let parts: Vec<&str> = line.split_whitespace().collect();
53 if parts.len() >= 3 {
54 let name = parts[0].to_string();
55 let url = parts[1].to_string();
56 let purpose = parts[2].trim_matches(|c| c == '(' || c == ')');
57
58 if purpose == "fetch" {
60 remotes.insert(name, url);
61 }
62 }
63 }
64
65 Ok(remotes)
66}
67
68pub fn get_head_commit_hash(cwd: &Path) -> Result<Option<String>> {
76 let output = Command::new("git")
77 .args(["rev-parse", "--short", "HEAD"])
78 .current_dir(cwd)
79 .output()
80 .with_context(|| format!("Failed to run git rev-parse in {}", cwd.display()))?;
81
82 if !output.status.success() {
83 return Ok(None);
84 }
85
86 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
87
88 if hash.is_empty() { Ok(None) } else { Ok(Some(hash)) }
89}
90
91pub fn get_git_repo_root(cwd: &Path) -> Result<Option<String>> {
99 let output = Command::new("git")
100 .args(["rev-parse", "--show-toplevel"])
101 .current_dir(cwd)
102 .output()
103 .with_context(|| format!("Failed to run git rev-parse --show-toplevel in {}", cwd.display()))?;
104
105 if !output.status.success() {
106 return Ok(None);
107 }
108
109 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
110
111 if root.is_empty() { Ok(None) } else { Ok(Some(root)) }
112}
113
114pub fn collect_git_info(cwd: &Path) -> Result<GitInfo> {
123 let remotes = get_git_remote_urls(cwd)?;
124 let head_commit = get_head_commit_hash(cwd)?;
125 let repo_root = get_git_repo_root(cwd)?;
126
127 Ok(GitInfo { remotes, head_commit, repo_root })
128}
129
130#[must_use]
138pub fn is_git_repo(cwd: &Path) -> bool {
139 Command::new("git")
140 .args(["rev-parse", "--git-dir"])
141 .current_dir(cwd)
142 .stdout(std::process::Stdio::null())
143 .stderr(std::process::Stdio::null())
144 .status()
145 .map(|status| status.success())
146 .unwrap_or(false)
147}
148
149pub async fn get_git_remote_urls_async(cwd: std::path::PathBuf) -> Result<BTreeMap<String, String>> {
152 tokio::task::spawn_blocking(move || get_git_remote_urls(&cwd))
153 .await
154 .context("Git remote URLs task panicked")?
155}
156
157pub async fn get_head_commit_hash_async(cwd: std::path::PathBuf) -> Result<Option<String>> {
160 tokio::task::spawn_blocking(move || get_head_commit_hash(&cwd))
161 .await
162 .context("Git HEAD hash task panicked")?
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::path::PathBuf;
169 use vtcode_commons::canonicalize;
170
171 #[test]
172 fn test_is_git_repo() {
173 let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
175 assert!(is_git_repo(&repo_root));
176 }
177
178 #[test]
179 fn test_get_git_repo_root() {
180 let manifest_dir = canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR"))).unwrap();
181 let root = get_git_repo_root(&manifest_dir).unwrap();
182 assert!(root.is_some());
183 let root = canonicalize(PathBuf::from(root.unwrap())).unwrap();
184 assert!(
185 manifest_dir == root || manifest_dir.starts_with(&root),
186 "repo root {} should be an ancestor of manifest dir {}",
187 root.display(),
188 manifest_dir.display()
189 );
190 }
191
192 #[test]
193 fn test_get_head_commit_hash() {
194 let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
195 let hash = get_head_commit_hash(&repo_root).unwrap();
196 assert!(hash.is_some());
197 let hash_str = hash.unwrap();
199 assert!(hash_str.len() >= 7 && hash_str.len() <= 12);
200 assert!(hash_str.chars().all(|c| c.is_ascii_hexdigit()));
202 }
203
204 #[test]
205 fn test_collect_git_info() {
206 let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
207 let info = collect_git_info(&repo_root).unwrap();
208
209 assert!(info.head_commit.is_some());
211
212 assert!(info.repo_root.is_some());
214
215 }
218
219 #[test]
220 fn test_non_git_directory() {
221 use std::fs;
222 use tempfile::TempDir;
223
224 let temp_dir = TempDir::new().unwrap();
225 let non_git_path = temp_dir.path().join("not_a_repo");
226 fs::create_dir(&non_git_path).unwrap();
227
228 assert!(!is_git_repo(&non_git_path));
230 assert!(get_git_remote_urls(&non_git_path).unwrap().is_empty());
231 assert!(get_head_commit_hash(&non_git_path).unwrap().is_none());
232 assert!(get_git_repo_root(&non_git_path).unwrap().is_none());
233 }
234}