Skip to main content

sley_remote/
resolve.rs

1//! Remote URL resolution and transport selection for embedders.
2//!
3//! Callers pass an effective [`GitConfig`] snapshot (see [`sley_config::load_effective_config`])
4//! plus a remote name or literal URL; these helpers return rewritten URLs and the
5//! corresponding [`FetchSource`] / [`PushDestination`] values expected by
6//! [`crate::fetch`] and [`crate::push`].
7
8use 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/// Explicit repository/process context for resolving a CLI remote without
21/// consulting global cwd or repository-discovery state.
22#[derive(Debug, Clone, Copy)]
23pub struct RemoteResolutionContext<'a> {
24    /// Invocation working directory used for literal relative paths.
25    pub cwd: &'a Path,
26    /// Current repository git directory, when the invocation has one.
27    pub local_git_dir: Option<&'a Path>,
28    /// Effective current-repository configuration, including injected values.
29    pub config: Option<&'a GitConfig>,
30}
31
32/// A remote name/URL after config lookup and `insteadOf` rewriting.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ResolvedRemote {
35    pub url: String,
36    pub transport: RemoteTransport,
37}
38
39/// Resolve a remote name or literal URL using only explicit context.
40pub 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
52/// Resolve a remote name/path to a concrete local git directory.
53///
54/// This preserves Git's precedence: a configured remote name first, then the
55/// literal path relative to the invocation cwd, then an `insteadOf` rewrite.
56pub 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
86/// Resolve the fetch URL for `remote` using `config` (name lookup + `insteadOf`).
87pub fn fetch_url(config: &GitConfig, remote: &str) -> String {
88    resolve_remote_fetch_url(config, remote)
89}
90
91/// Resolve the push URL for `remote` using `config` (`pushurl` + `pushInsteadOf`).
92pub fn push_url(config: &GitConfig, remote: &str) -> String {
93    resolve_remote_push_url(config, remote)
94}
95
96/// Classify a rewritten URL for capability checks.
97pub 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
109/// Build a [`FetchSource`] from a resolved URL.
110///
111/// `relative_base` is the directory relative paths are resolved against (typically
112/// the repository working tree, or the parent of `.git` for a bare repo).
113pub 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
118/// Build a [`PushDestination`] from a resolved URL.
119pub 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
124/// Resolve fetch URL rewriting and transport source in one step.
125pub 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
135/// Resolve push URL rewriting and transport destination in one step.
136pub 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
368/// Discover the git directory containing `start` (working tree or bare repo).
369fn 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}