Skip to main content

vtcode_core/
git_info.rs

1//! Git repository information collection
2//!
3//! This module provides utilities for collecting git metadata from the workspace,
4//! similar to OpenAI Codex PR #10145. It collects remote URLs, HEAD commit hash,
5//! and repository root path for inclusion in LLM request headers.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::Path;
11use std::process::Command;
12
13/// Git repository information for a workspace
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct GitInfo {
16    /// Remote URLs keyed by remote name (e.g., "origin")
17    pub remotes: BTreeMap<String, String>,
18    /// HEAD commit hash (short form)
19    pub head_commit: Option<String>,
20    /// Repository root path
21    pub repo_root: Option<String>,
22}
23
24/// Get git remote URLs for fetch remotes in the repository at the given path.
25/// Returns a BTreeMap mapping remote names to their fetch URLs.
26///
27/// # Arguments
28/// * `cwd` - The working directory to run git commands in
29///
30/// # Returns
31/// A BTreeMap where keys are remote names (e.g., "origin") and values are fetch URLs.
32/// Returns an empty map if not in a git repository or if no remotes are configured.
33pub 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        // Not a git repository or git not available
42        return Ok(BTreeMap::new());
43    }
44
45    let stdout = String::from_utf8_lossy(&output.stdout);
46    let mut remotes = BTreeMap::new();
47
48    // Parse output like:
49    // origin  https://github.com/user/repo.git (fetch)
50    // origin  https://github.com/user/repo.git (push)
51    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            // Only collect fetch remotes to avoid duplicates
59            if purpose == "fetch" {
60                remotes.insert(name, url);
61            }
62        }
63    }
64
65    Ok(remotes)
66}
67
68/// Get the HEAD commit hash (short form) for the repository at the given path.
69///
70/// # Arguments
71/// * `cwd` - The working directory to run git commands in
72///
73/// # Returns
74/// The short commit hash (7 characters) of HEAD, or None if not in a git repository.
75pub 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
91/// Get the repository root path for the given working directory.
92///
93/// # Arguments
94/// * `cwd` - The working directory to run git commands in
95///
96/// # Returns
97/// The absolute path to the repository root, or None if not in a git repository.
98pub 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
114/// Collect all git information for a workspace.
115///
116/// # Arguments
117/// * `cwd` - The working directory to collect git info from
118///
119/// # Returns
120/// A GitInfo struct containing remote URLs, HEAD commit hash, and repo root.
121/// Returns default GitInfo if not in a git repository.
122pub 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/// Check if the given path is inside a git repository.
131///
132/// # Arguments
133/// * `cwd` - The working directory to check
134///
135/// # Returns
136/// true if inside a git repository, false otherwise.
137#[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
149/// Async variant of [`get_git_remote_urls`]. Runs the git subprocess on the
150/// blocking thread pool.
151pub 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
157/// Async variant of [`get_head_commit_hash`]. Runs the git subprocess on the
158/// blocking thread pool.
159pub 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        // The vtcode repo itself should be a git repo
174        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        // Short hash should be 7-12 characters
198        let hash_str = hash.unwrap();
199        assert!(hash_str.len() >= 7 && hash_str.len() <= 12);
200        // Should only contain hex characters
201        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        // Should have a HEAD commit
210        assert!(info.head_commit.is_some());
211
212        // Should have a repo root
213        assert!(info.repo_root.is_some());
214
215        // Remotes may or may not be present depending on git config
216        // but the function should not error
217    }
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        // Should return empty results without error
229        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}