1use std::collections::HashMap;
2use std::fs::File;
3use std::io::{BufRead, BufReader};
4use std::path::{Path, PathBuf};
5use std::sync::{LazyLock, RwLock};
6
7static GIT_URL_CACHE: LazyLock<RwLock<HashMap<PathBuf, String>>> =
10 LazyLock::new(|| RwLock::new(HashMap::with_capacity(20)));
11
12pub 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 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 let url = get_git_remote_url_impl(cwd);
51
52 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
73fn 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 if trimmed.starts_with('[') && trimmed.ends_with(']') {
92 in_origin_section = trimmed.starts_with("[remote \"origin\"");
93 continue;
94 }
95
96 if in_origin_section && trimmed.starts_with("url = ") {
98 let url = trimmed.trim_start_matches("url = ").trim();
99 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}