1use std::path::{Path, PathBuf};
9
10use sley_config::GitConfig;
11use sley_config::remotes::{
12 remote_config_values, remote_exists, resolve_remote_fetch_url, resolve_remote_push_url,
13};
14use sley_core::{GitError, Result};
15use sley_odb::repository_common_dir;
16use sley_transport::{RemoteTransport, RemoteUrl, parse_remote_url};
17
18use crate::{FetchSource, PushDestination, RemoteTransportKind};
19
20#[derive(Debug, Clone, Copy)]
23pub struct RemoteResolutionContext<'a> {
24 pub cwd: &'a Path,
26 pub local_git_dir: Option<&'a Path>,
28 pub config: Option<&'a GitConfig>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ResolvedRemote {
35 pub url: String,
36 pub transport: RemoteTransport,
37}
38
39pub fn resolve_remote(
41 context: RemoteResolutionContext<'_>,
42 repository: &str,
43) -> Result<ResolvedRemote> {
44 let url = context
45 .config
46 .map(|config| resolve_remote_fetch_url(config, repository))
47 .unwrap_or_else(|| repository.to_string());
48 let transport = parse_remote_url(&url)?.transport;
49 Ok(ResolvedRemote { url, transport })
50}
51
52pub fn resolve_local_remote_git_dir(
57 context: RemoteResolutionContext<'_>,
58 repository: &str,
59) -> Result<PathBuf> {
60 if let (Some(git_dir), Some(config)) = (context.local_git_dir, context.config)
61 && remote_exists(config, repository)
62 {
63 return resolve_configured_local_remote_git_dir(config, repository, git_dir, context.cwd);
64 }
65 if let Ok(path) = local_repository_path_from_url(repository, context.cwd)
66 && let Ok(git_dir) = discover_local_git_dir(&path)
67 {
68 return Ok(git_dir);
69 }
70 let local_git_dir = context
71 .local_git_dir
72 .ok_or_else(|| GitError::repository_not_found("not a git repository"))?;
73 let config = context
74 .config
75 .ok_or_else(|| GitError::repository_not_found("not a git repository"))?;
76 let rewritten = resolve_remote_fetch_url(config, repository);
77 if rewritten != repository
78 && let Ok(path) = local_repository_path_from_url(&rewritten, context.cwd)
79 && let Ok(git_dir) = discover_local_git_dir(&path)
80 {
81 return Ok(git_dir);
82 }
83 resolve_configured_local_remote_git_dir(config, repository, local_git_dir, context.cwd)
84}
85
86pub fn fetch_url(config: &GitConfig, remote: &str) -> String {
88 resolve_remote_fetch_url(config, remote)
89}
90
91pub fn push_url(config: &GitConfig, remote: &str) -> String {
93 resolve_remote_push_url(config, remote)
94}
95
96pub fn transport_kind_for_url(url: &str) -> Result<Option<RemoteTransportKind>> {
98 if url.ends_with(".bundle") {
99 return Ok(Some(RemoteTransportKind::Bundle));
100 }
101 Ok(match parse_remote_url(url)?.transport {
102 RemoteTransport::Http | RemoteTransport::Https => Some(RemoteTransportKind::Http),
103 RemoteTransport::Ssh | RemoteTransport::Ext => Some(RemoteTransportKind::Ssh),
104 RemoteTransport::Git => Some(RemoteTransportKind::Git),
105 RemoteTransport::Local | RemoteTransport::File => Some(RemoteTransportKind::Local),
106 })
107}
108
109pub fn fetch_source_for_url(url: &str, relative_base: &Path) -> Result<FetchSource> {
114 let parsed = parse_remote_url(url)?;
115 source_from_parsed(&parsed, relative_base).map(FetchSource::from_concrete)
116}
117
118pub fn push_destination_for_url(url: &str, relative_base: &Path) -> Result<PushDestination> {
120 let parsed = parse_remote_url(url)?;
121 source_from_parsed(&parsed, relative_base).map(PushDestination::from_concrete)
122}
123
124pub fn resolve_fetch_source(
126 config: &GitConfig,
127 remote: &str,
128 relative_base: &Path,
129) -> Result<(String, FetchSource)> {
130 let url = fetch_url(config, remote);
131 let source = fetch_source_for_url(&url, relative_base)?;
132 Ok((url, source))
133}
134
135pub fn resolve_push_destination(
137 config: &GitConfig,
138 remote: &str,
139 relative_base: &Path,
140) -> Result<(String, PushDestination)> {
141 let url = push_url(config, remote);
142 let destination = push_destination_for_url(&url, relative_base)?;
143 Ok((url, destination))
144}
145
146enum ConcreteRemote {
147 Network(RemoteUrl),
148 Local {
149 git_dir: PathBuf,
150 common_git_dir: PathBuf,
151 },
152}
153
154impl FetchSource {
155 fn from_concrete(source: ConcreteRemote) -> Self {
156 match source {
157 ConcreteRemote::Network(remote) => match remote.transport {
158 RemoteTransport::Http | RemoteTransport::Https => Self::Http(remote),
159 RemoteTransport::Ssh | RemoteTransport::Ext => Self::Ssh(remote),
160 RemoteTransport::Git => Self::Git {
161 remote,
162 protocol_v2: false,
163 },
164 RemoteTransport::Local | RemoteTransport::File => {
165 unreachable!("local remotes use FetchSource::Local")
166 }
167 },
168 ConcreteRemote::Local {
169 git_dir,
170 common_git_dir,
171 } => Self::Local {
172 git_dir,
173 common_git_dir,
174 },
175 }
176 }
177}
178
179impl PushDestination {
180 fn from_concrete(source: ConcreteRemote) -> Self {
181 match source {
182 ConcreteRemote::Network(remote) => match remote.transport {
183 RemoteTransport::Http | RemoteTransport::Https => Self::Http(remote),
184 RemoteTransport::Ssh | RemoteTransport::Ext => Self::Ssh(remote),
185 RemoteTransport::Git => Self::Git(remote),
186 RemoteTransport::Local | RemoteTransport::File => {
187 unreachable!("local remotes use PushDestination::Local")
188 }
189 },
190 ConcreteRemote::Local {
191 git_dir,
192 common_git_dir,
193 } => Self::Local {
194 git_dir,
195 common_git_dir,
196 },
197 }
198 }
199}
200
201fn source_from_parsed(parsed: &RemoteUrl, relative_base: &Path) -> Result<ConcreteRemote> {
202 match parsed.transport {
203 RemoteTransport::Http
204 | RemoteTransport::Https
205 | RemoteTransport::Ssh
206 | RemoteTransport::Ext
207 | RemoteTransport::Git => Ok(ConcreteRemote::Network(parsed.clone())),
208 RemoteTransport::Local | RemoteTransport::File => {
209 let repo_path = local_repository_path(parsed, relative_base)?;
210 let git_dir = discover_git_dir(&repo_path)?;
211 Ok(ConcreteRemote::Local {
212 common_git_dir: repository_common_dir(&git_dir),
213 git_dir,
214 })
215 }
216 }
217}
218
219fn local_repository_path(parsed: &RemoteUrl, relative_base: &Path) -> Result<PathBuf> {
220 Ok(match parsed.transport {
221 RemoteTransport::Local => {
222 let path = PathBuf::from(&parsed.path);
223 if path.is_absolute() {
224 path
225 } else {
226 relative_base.join(path)
227 }
228 }
229 RemoteTransport::File => PathBuf::from(&parsed.path),
230 _ => {
231 return Err(GitError::Unsupported("expected a local remote URL".into()));
232 }
233 })
234}
235
236pub fn resolve_configured_local_remote_git_dir(
237 config: &GitConfig,
238 name: &str,
239 git_dir: &Path,
240 cwd: &Path,
241) -> Result<PathBuf> {
242 let url = remote_config_values(config, name, "url")
243 .into_iter()
244 .next()
245 .ok_or_else(|| GitError::not_found(format!("remote {name} url")))?;
246 let url = resolve_remote_fetch_url(config, &url);
247 let parsed = parse_remote_url(&url)?;
248 let remote_path = match parsed.transport {
249 RemoteTransport::Local => {
250 let path = PathBuf::from(parsed.path);
251 if path.is_absolute() {
252 path
253 } else {
254 repository_relative_path_base(git_dir, cwd)?.join(path)
255 }
256 }
257 RemoteTransport::File => PathBuf::from(percent_decode_url_path(&parsed.path)?),
258 RemoteTransport::Ssh
259 | RemoteTransport::Ext
260 | RemoteTransport::Git
261 | RemoteTransport::Http
262 | RemoteTransport::Https => {
263 return Err(GitError::Unsupported(
264 "remote discovery for non-local transports".into(),
265 ));
266 }
267 };
268 discover_local_git_dir(&remote_path)
269}
270
271fn repository_relative_path_base(git_dir: &Path, cwd: &Path) -> Result<PathBuf> {
272 if git_dir.file_name().is_some_and(|name| name == ".git") {
273 return git_dir
274 .parent()
275 .map(Path::to_path_buf)
276 .ok_or_else(|| GitError::InvalidPath("git dir has no parent".into()));
277 }
278 Ok(cwd.to_path_buf())
279}
280
281fn local_repository_path_from_url(repository: &str, cwd: &Path) -> Result<PathBuf> {
282 let parsed = parse_remote_url(repository)?;
283 match parsed.transport {
284 RemoteTransport::Local => {
285 let path = PathBuf::from(parsed.path);
286 Ok(if path.is_absolute() {
287 path
288 } else {
289 cwd.join(path)
290 })
291 }
292 RemoteTransport::File => Ok(PathBuf::from(percent_decode_url_path(&parsed.path)?)),
293 RemoteTransport::Ssh
294 | RemoteTransport::Ext
295 | RemoteTransport::Git
296 | RemoteTransport::Http
297 | RemoteTransport::Https => Err(GitError::Unsupported(
298 "local remote resolution requires a local repository".into(),
299 )),
300 }
301}
302
303pub fn discover_local_git_dir(path: &Path) -> Result<PathBuf> {
304 let dot_git_path = path_with_dot_git_suffix(path);
305 let candidates = [
306 path.join(".git"),
307 path.to_path_buf(),
308 dot_git_path.join(".git"),
309 dot_git_path,
310 ];
311 for candidate in candidates {
312 if is_git_dir(&candidate) {
313 return Ok(candidate);
314 }
315 if candidate.is_file()
316 && let Some(git_dir) = read_gitdir_link(&candidate)?
317 && is_git_dir(&git_dir)
318 {
319 return std::fs::canonicalize(git_dir).map_err(|err| GitError::Io(err.to_string()));
320 }
321 }
322 Err(GitError::repository_not_found("not a git repository"))
323}
324
325fn path_with_dot_git_suffix(path: &Path) -> PathBuf {
326 let mut suffixed = path.as_os_str().to_os_string();
327 suffixed.push(".git");
328 PathBuf::from(suffixed)
329}
330
331fn percent_decode_url_path(value: &str) -> Result<String> {
332 let bytes = value.as_bytes();
333 let mut decoded = Vec::with_capacity(bytes.len());
334 let mut index = 0;
335 while index < bytes.len() {
336 if bytes[index] == b'%' {
337 if index + 2 >= bytes.len() {
338 return Err(GitError::InvalidPath(format!(
339 "invalid percent-encoded path {value:?}"
340 )));
341 }
342 let high = percent_hex_value(bytes[index + 1]).ok_or_else(|| {
343 GitError::InvalidPath(format!("invalid percent-encoded path {value:?}"))
344 })?;
345 let low = percent_hex_value(bytes[index + 2]).ok_or_else(|| {
346 GitError::InvalidPath(format!("invalid percent-encoded path {value:?}"))
347 })?;
348 decoded.push((high << 4) | low);
349 index += 3;
350 } else {
351 decoded.push(bytes[index]);
352 index += 1;
353 }
354 }
355 String::from_utf8(decoded)
356 .map_err(|_| GitError::InvalidPath(format!("invalid utf-8 file URL path {value:?}")))
357}
358
359fn percent_hex_value(byte: u8) -> Option<u8> {
360 match byte {
361 b'0'..=b'9' => Some(byte - b'0'),
362 b'a'..=b'f' => Some(byte - b'a' + 10),
363 b'A'..=b'F' => Some(byte - b'A' + 10),
364 _ => None,
365 }
366}
367
368fn discover_git_dir(start: &Path) -> Result<PathBuf> {
370 for candidate in start.ancestors() {
371 let dot_git = candidate.join(".git");
372 if dot_git.is_dir() {
373 return Ok(dot_git);
374 }
375 if dot_git.is_file()
376 && let Some(git_dir) = read_gitdir_link(&dot_git)?
377 && is_git_dir(&git_dir)
378 {
379 return Ok(git_dir);
380 }
381 if is_git_dir(candidate) {
382 return Ok(candidate.to_path_buf());
383 }
384 }
385 Err(GitError::repository_not_found(format!(
386 "not a git repository: {}",
387 start.display()
388 )))
389}
390
391fn is_git_dir(path: &Path) -> bool {
392 path.join("HEAD").is_file()
393 && (path.join("objects").is_dir() || path.join("commondir").is_file())
394}
395
396fn read_gitdir_link(path: &Path) -> Result<Option<PathBuf>> {
397 let contents = std::fs::read_to_string(path)?;
398 let Some(target) = contents.trim().strip_prefix("gitdir:") else {
399 return Ok(None);
400 };
401 let target = PathBuf::from(target.trim());
402 Ok(Some(if target.is_absolute() {
403 target
404 } else {
405 path.parent().unwrap_or_else(|| Path::new("")).join(target)
406 }))
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use sley_config::{ConfigEntry, ConfigSection, GitConfig};
413 use std::sync::atomic::{AtomicU64, Ordering};
414
415 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
416
417 #[test]
418 fn instead_of_rewrites_fetch_url() {
419 let config = GitConfig {
420 preamble: Vec::new(),
421 suffix: Vec::new(),
422 sections: vec![
423 ConfigSection::new(
424 "remote",
425 Some("origin".into()),
426 vec![ConfigEntry::new(
427 "url",
428 Some("git@github.com:org/repo.git".into()),
429 )],
430 ),
431 ConfigSection::new(
432 "url",
433 Some("https://github.com/".into()),
434 vec![ConfigEntry::new(
435 "insteadOf",
436 Some("git@github.com:".into()),
437 )],
438 ),
439 ],
440 };
441 assert_eq!(
442 fetch_url(&config, "origin"),
443 "https://github.com/org/repo.git"
444 );
445 }
446
447 #[test]
448 fn push_url_prefers_pushurl() {
449 let config = GitConfig {
450 preamble: Vec::new(),
451 suffix: Vec::new(),
452 sections: vec![ConfigSection::new(
453 "remote",
454 Some("origin".into()),
455 vec![
456 ConfigEntry::new("url", Some("https://fetch.example/x.git".into())),
457 ConfigEntry::new("pushurl", Some("https://push.example/x.git".into())),
458 ],
459 )],
460 };
461 assert_eq!(push_url(&config, "origin"), "https://push.example/x.git");
462 }
463
464 #[test]
465 fn git_scheme_routes_to_native_git_transport() {
466 let url = "git://127.0.0.1/repo.git";
467
468 assert_eq!(
469 transport_kind_for_url(url).expect("kind"),
470 Some(RemoteTransportKind::Git)
471 );
472 assert!(matches!(
473 fetch_source_for_url(url, Path::new(".")).expect("fetch source"),
474 FetchSource::Git { .. }
475 ));
476 assert!(matches!(
477 push_destination_for_url(url, Path::new(".")).expect("push destination"),
478 PushDestination::Git(_)
479 ));
480 }
481
482 #[test]
483 fn local_resolution_uses_only_injected_cwd_and_config() {
484 let root = std::env::temp_dir().join(format!(
485 "sley-remote-resolve-{}-{}",
486 std::process::id(),
487 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
488 ));
489 let local = root.join("local");
490 let git_dir = local.join(".git");
491 std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
492 std::fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("HEAD");
493 let config = GitConfig {
494 preamble: Vec::new(),
495 suffix: Vec::new(),
496 sections: vec![ConfigSection::new(
497 "remote",
498 Some("origin".into()),
499 vec![ConfigEntry::new("url", Some("local".into()))],
500 )],
501 };
502 let caller_git_dir = root.join(".git");
503 let context = RemoteResolutionContext {
504 cwd: &root,
505 local_git_dir: Some(&caller_git_dir),
506 config: Some(&config),
507 };
508 assert_eq!(
509 resolve_local_remote_git_dir(context, "origin").expect("local remote"),
510 git_dir
511 );
512 assert_eq!(
513 resolve_remote(context, "origin")
514 .expect("resolved remote")
515 .url,
516 "local"
517 );
518 std::fs::remove_dir_all(root).expect("cleanup");
519 }
520}