Skip to main content

vct_core/utils/
git.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::io::{BufRead, BufReader};
4use std::path::{Path, PathBuf};
5use std::sync::{LazyLock, RwLock};
6
7// Global cache for Git remote URLs (thread-safe)
8// Key: original absolute or canonical path, Value: remote URL
9static GIT_URL_CACHE: LazyLock<RwLock<HashMap<PathBuf, String>>> =
10    LazyLock::new(|| RwLock::new(HashMap::with_capacity(20)));
11
12/// Returns the `origin` remote URL for the git repository at `cwd`.
13///
14/// Results are memoized in a process-global, thread-safe cache under both the
15/// original absolute path and its canonical path. This avoids repeated
16/// canonicalization while still letting symlinked variants share one lookup.
17/// The returned URL has any trailing `.git` stripped. Returns an empty
18/// `String` when `cwd` is empty, is not a git working tree, has no `origin`
19/// remote, or the config cannot be read. Callers treat the empty string as
20/// "no remote".
21pub fn get_git_remote_url<P: AsRef<Path>>(cwd: P) -> String {
22    let cwd = cwd.as_ref();
23    if cwd.as_os_str().is_empty() {
24        return String::new();
25    }
26
27    let original_path = cwd.to_path_buf();
28
29    // Provider logs normally carry absolute workspaces. Check that stable key
30    // before doing filesystem work so a hot session never canonicalizes again.
31    if cwd.is_absolute()
32        && let Some(url) = cached_url(cwd)
33    {
34        return url;
35    }
36
37    let canonical_path = std::fs::canonicalize(cwd).ok();
38    if let Some(path) = canonical_path.as_deref()
39        && let Some(url) = cached_url(path)
40    {
41        if cwd.is_absolute()
42            && let Ok(mut cache) = GIT_URL_CACHE.write()
43        {
44            cache.insert(original_path, url.clone());
45        }
46        return url;
47    }
48
49    // Cache miss - perform actual lookup
50    let url = get_git_remote_url_impl(cwd);
51
52    // Keep both aliases. The original absolute path avoids repeated
53    // canonicalization, while the canonical path deduplicates symlinks.
54    if let Ok(mut cache) = GIT_URL_CACHE.write() {
55        if cwd.is_absolute() || canonical_path.is_none() {
56            cache.insert(original_path, url.clone());
57        }
58        if let Some(path) = canonical_path {
59            cache.insert(path, url.clone());
60        }
61    }
62
63    url
64}
65
66fn cached_url(path: &Path) -> Option<String> {
67    GIT_URL_CACHE
68        .read()
69        .ok()
70        .and_then(|cache| cache.get(path).cloned())
71}
72
73/// Parses `<cwd>/.git/config` and returns the `[remote "origin"]` URL,
74/// or an empty string if absent. The uncached inner worker behind
75/// [`get_git_remote_url`].
76fn get_git_remote_url_impl(cwd: &Path) -> String {
77    let git_config = cwd.join(".git").join("config");
78
79    let file = match File::open(&git_config) {
80        Ok(f) => f,
81        Err(_) => return String::new(),
82    };
83
84    let reader = BufReader::new(file);
85    let mut in_origin_section = false;
86
87    for line in reader.lines().map_while(Result::ok) {
88        let trimmed = line.trim();
89
90        // Check for section headers
91        if trimmed.starts_with('[') && trimmed.ends_with(']') {
92            in_origin_section = trimmed.starts_with("[remote \"origin\"");
93            continue;
94        }
95
96        // Look for url in origin section
97        if in_origin_section && trimmed.starts_with("url = ") {
98            let url = trimmed.trim_start_matches("url = ").trim();
99            // Remove .git suffix if present
100            let url = url.strip_suffix(".git").unwrap_or(url);
101            return url.to_string();
102        }
103    }
104
105    String::new()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::fs::{self, File};
112    use std::io::Write;
113    use tempfile::tempdir;
114
115    #[test]
116    fn test_get_git_remote_url_no_git_dir() {
117        // Test directory without .git
118        let dir = tempdir().unwrap();
119
120        let url = get_git_remote_url(dir.path());
121        assert_eq!(url, "");
122    }
123
124    #[test]
125    fn test_get_git_remote_url_empty_cwd() {
126        assert_eq!(get_git_remote_url(""), "");
127    }
128
129    #[test]
130    fn test_get_git_remote_url_empty_config() {
131        // Test with empty git config
132        let dir = tempdir().unwrap();
133        let git_dir = dir.path().join(".git");
134        fs::create_dir(&git_dir).unwrap();
135
136        let config_path = git_dir.join("config");
137        File::create(&config_path).unwrap();
138
139        let url = get_git_remote_url(dir.path());
140        assert_eq!(url, "");
141    }
142
143    #[test]
144    fn test_get_git_remote_url_with_origin() {
145        // Test with valid git config containing origin
146        let dir = tempdir().unwrap();
147        let git_dir = dir.path().join(".git");
148        fs::create_dir(&git_dir).unwrap();
149
150        let config_path = git_dir.join("config");
151        let mut config = File::create(&config_path).unwrap();
152        writeln!(config, "[core]").unwrap();
153        writeln!(config, "    repositoryformatversion = 0").unwrap();
154        writeln!(config, "[remote \"origin\"]").unwrap();
155        writeln!(config, "    url = https://github.com/user/repo").unwrap();
156        writeln!(config, "    fetch = +refs/heads/*:refs/remotes/origin/*").unwrap();
157
158        let url = get_git_remote_url(dir.path());
159        assert_eq!(url, "https://github.com/user/repo");
160    }
161
162    #[test]
163    fn test_get_git_remote_url_strips_git_suffix() {
164        // Test that .git suffix is removed
165        let dir = tempdir().unwrap();
166        let git_dir = dir.path().join(".git");
167        fs::create_dir(&git_dir).unwrap();
168
169        let config_path = git_dir.join("config");
170        let mut config = File::create(&config_path).unwrap();
171        writeln!(config, "[remote \"origin\"]").unwrap();
172        writeln!(config, "    url = https://github.com/user/repo.git").unwrap();
173
174        let url = get_git_remote_url(dir.path());
175        assert_eq!(url, "https://github.com/user/repo");
176    }
177
178    #[test]
179    fn test_get_git_remote_url_ssh_format() {
180        // Test with SSH URL format
181        let dir = tempdir().unwrap();
182        let git_dir = dir.path().join(".git");
183        fs::create_dir(&git_dir).unwrap();
184
185        let config_path = git_dir.join("config");
186        let mut config = File::create(&config_path).unwrap();
187        writeln!(config, "[remote \"origin\"]").unwrap();
188        writeln!(config, "    url = git@github.com:user/repo.git").unwrap();
189
190        let url = get_git_remote_url(dir.path());
191        assert_eq!(url, "git@github.com:user/repo");
192    }
193
194    #[test]
195    fn test_get_git_remote_url_multiple_remotes() {
196        // Test with multiple remotes (should return origin)
197        let dir = tempdir().unwrap();
198        let git_dir = dir.path().join(".git");
199        fs::create_dir(&git_dir).unwrap();
200
201        let config_path = git_dir.join("config");
202        let mut config = File::create(&config_path).unwrap();
203        writeln!(config, "[remote \"upstream\"]").unwrap();
204        writeln!(config, "    url = https://github.com/upstream/repo").unwrap();
205        writeln!(config, "[remote \"origin\"]").unwrap();
206        writeln!(config, "    url = https://github.com/user/repo").unwrap();
207
208        let url = get_git_remote_url(dir.path());
209        assert_eq!(url, "https://github.com/user/repo");
210    }
211
212    #[test]
213    fn test_get_git_remote_url_caching() {
214        // Test that results are cached (call twice)
215        let dir = tempdir().unwrap();
216        let git_dir = dir.path().join(".git");
217        fs::create_dir(&git_dir).unwrap();
218
219        let config_path = git_dir.join("config");
220        let mut config = File::create(&config_path).unwrap();
221        writeln!(config, "[remote \"origin\"]").unwrap();
222        writeln!(config, "    url = https://github.com/user/repo").unwrap();
223
224        let url1 = get_git_remote_url(dir.path());
225        let url2 = get_git_remote_url(dir.path());
226
227        assert_eq!(url1, url2);
228        assert_eq!(url1, "https://github.com/user/repo");
229    }
230
231    #[cfg(unix)]
232    #[test]
233    fn test_absolute_symlink_cache_hit_does_not_recanonicalize() {
234        use std::os::unix::fs::symlink;
235
236        let dir = tempdir().unwrap();
237        let repo = dir.path().join("repo");
238        let alias = dir.path().join("alias");
239        let git_dir = repo.join(".git");
240        fs::create_dir_all(&git_dir).unwrap();
241        let mut config = File::create(git_dir.join("config")).unwrap();
242        writeln!(config, "[remote \"origin\"]").unwrap();
243        writeln!(config, "    url = https://github.com/user/cached.git").unwrap();
244        drop(config);
245        symlink(&repo, &alias).unwrap();
246
247        assert_eq!(get_git_remote_url(&alias), "https://github.com/user/cached");
248
249        fs::remove_dir_all(&repo).unwrap();
250        assert_eq!(get_git_remote_url(&alias), "https://github.com/user/cached");
251    }
252
253    #[test]
254    fn test_get_git_remote_url_whitespace() {
255        // Test with extra whitespace
256        let dir = tempdir().unwrap();
257        let git_dir = dir.path().join(".git");
258        fs::create_dir(&git_dir).unwrap();
259
260        let config_path = git_dir.join("config");
261        let mut config = File::create(&config_path).unwrap();
262        writeln!(config, "[remote \"origin\"]").unwrap();
263        writeln!(config, "    url =   https://github.com/user/repo   ").unwrap();
264
265        let url = get_git_remote_url(dir.path());
266        assert_eq!(url, "https://github.com/user/repo");
267    }
268
269    #[test]
270    fn test_get_git_remote_url_no_origin() {
271        // Test with no origin remote
272        let dir = tempdir().unwrap();
273        let git_dir = dir.path().join(".git");
274        fs::create_dir(&git_dir).unwrap();
275
276        let config_path = git_dir.join("config");
277        let mut config = File::create(&config_path).unwrap();
278        writeln!(config, "[remote \"upstream\"]").unwrap();
279        writeln!(config, "    url = https://github.com/upstream/repo").unwrap();
280
281        let url = get_git_remote_url(dir.path());
282        assert_eq!(url, "");
283    }
284
285    #[test]
286    fn test_get_git_remote_url_gitlab() {
287        // Test with GitLab URL
288        let dir = tempdir().unwrap();
289        let git_dir = dir.path().join(".git");
290        fs::create_dir(&git_dir).unwrap();
291
292        let config_path = git_dir.join("config");
293        let mut config = File::create(&config_path).unwrap();
294        writeln!(config, "[remote \"origin\"]").unwrap();
295        writeln!(config, "    url = https://gitlab.com/user/repo.git").unwrap();
296
297        let url = get_git_remote_url(dir.path());
298        assert_eq!(url, "https://gitlab.com/user/repo");
299    }
300
301    #[test]
302    fn test_get_git_remote_url_bitbucket() {
303        // Test with Bitbucket URL
304        let dir = tempdir().unwrap();
305        let git_dir = dir.path().join(".git");
306        fs::create_dir(&git_dir).unwrap();
307
308        let config_path = git_dir.join("config");
309        let mut config = File::create(&config_path).unwrap();
310        writeln!(config, "[remote \"origin\"]").unwrap();
311        writeln!(config, "    url = https://bitbucket.org/user/repo.git").unwrap();
312
313        let url = get_git_remote_url(dir.path());
314        assert_eq!(url, "https://bitbucket.org/user/repo");
315    }
316
317    #[test]
318    fn test_get_git_remote_url_malformed_config() {
319        // Test with malformed config
320        let dir = tempdir().unwrap();
321        let git_dir = dir.path().join(".git");
322        fs::create_dir(&git_dir).unwrap();
323
324        let config_path = git_dir.join("config");
325        let mut config = File::create(&config_path).unwrap();
326        writeln!(config, "this is not valid git config").unwrap();
327        writeln!(config, "random text").unwrap();
328
329        let url = get_git_remote_url(dir.path());
330        assert_eq!(url, "");
331    }
332
333    #[test]
334    fn test_get_git_remote_url_url_without_git_suffix() {
335        // Test URL that doesn't have .git suffix
336        let dir = tempdir().unwrap();
337        let git_dir = dir.path().join(".git");
338        fs::create_dir(&git_dir).unwrap();
339
340        let config_path = git_dir.join("config");
341        let mut config = File::create(&config_path).unwrap();
342        writeln!(config, "[remote \"origin\"]").unwrap();
343        writeln!(config, "    url = https://github.com/user/repo").unwrap();
344
345        let url = get_git_remote_url(dir.path());
346        assert_eq!(url, "https://github.com/user/repo");
347    }
348
349    #[test]
350    fn test_get_git_remote_url_self_hosted() {
351        // Test with self-hosted git server
352        let dir = tempdir().unwrap();
353        let git_dir = dir.path().join(".git");
354        fs::create_dir(&git_dir).unwrap();
355
356        let config_path = git_dir.join("config");
357        let mut config = File::create(&config_path).unwrap();
358        writeln!(config, "[remote \"origin\"]").unwrap();
359        writeln!(config, "    url = https://git.company.com/team/project.git").unwrap();
360
361        let url = get_git_remote_url(dir.path());
362        assert_eq!(url, "https://git.company.com/team/project");
363    }
364
365    #[test]
366    fn test_get_git_remote_url_path_with_spaces() {
367        // Test directory path with spaces (though URL shouldn't have spaces)
368        let dir = tempdir().unwrap();
369        let subdir = dir.path().join("my project");
370        fs::create_dir(&subdir).unwrap();
371
372        let git_dir = subdir.join(".git");
373        fs::create_dir(&git_dir).unwrap();
374
375        let config_path = git_dir.join("config");
376        let mut config = File::create(&config_path).unwrap();
377        writeln!(config, "[remote \"origin\"]").unwrap();
378        writeln!(config, "    url = https://github.com/user/repo").unwrap();
379
380        let url = get_git_remote_url(&subdir);
381        assert_eq!(url, "https://github.com/user/repo");
382    }
383
384    #[test]
385    fn test_get_git_remote_url_empty_url_field() {
386        // Test with empty url field
387        let dir = tempdir().unwrap();
388        let git_dir = dir.path().join(".git");
389        fs::create_dir(&git_dir).unwrap();
390
391        let config_path = git_dir.join("config");
392        let mut config = File::create(&config_path).unwrap();
393        writeln!(config, "[remote \"origin\"]").unwrap();
394        writeln!(config, "    url = ").unwrap();
395
396        let url = get_git_remote_url(dir.path());
397        assert_eq!(url, "");
398    }
399}