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::{resolve_remote_fetch_url, resolve_remote_push_url};
12use sley_core::{GitError, Result};
13use sley_odb::repository_common_dir;
14use sley_transport::{RemoteTransport, RemoteUrl, parse_remote_url};
15
16use crate::{FetchSource, PushDestination, RemoteTransportKind};
17
18/// Resolve the fetch URL for `remote` using `config` (name lookup + `insteadOf`).
19pub fn fetch_url(config: &GitConfig, remote: &str) -> String {
20    resolve_remote_fetch_url(config, remote)
21}
22
23/// Resolve the push URL for `remote` using `config` (`pushurl` + `pushInsteadOf`).
24pub fn push_url(config: &GitConfig, remote: &str) -> String {
25    resolve_remote_push_url(config, remote)
26}
27
28/// Classify a rewritten URL for capability checks.
29pub fn transport_kind_for_url(url: &str) -> Result<Option<RemoteTransportKind>> {
30    if url.ends_with(".bundle") {
31        return Ok(Some(RemoteTransportKind::Bundle));
32    }
33    Ok(match parse_remote_url(url)?.transport {
34        RemoteTransport::Http | RemoteTransport::Https => Some(RemoteTransportKind::Http),
35        RemoteTransport::Ssh | RemoteTransport::Ext => Some(RemoteTransportKind::Ssh),
36        RemoteTransport::Git => Some(RemoteTransportKind::Git),
37        RemoteTransport::Local | RemoteTransport::File => Some(RemoteTransportKind::Local),
38    })
39}
40
41/// Build a [`FetchSource`] from a resolved URL.
42///
43/// `relative_base` is the directory relative paths are resolved against (typically
44/// the repository working tree, or the parent of `.git` for a bare repo).
45pub fn fetch_source_for_url(url: &str, relative_base: &Path) -> Result<FetchSource> {
46    let parsed = parse_remote_url(url)?;
47    source_from_parsed(&parsed, relative_base).map(FetchSource::from_concrete)
48}
49
50/// Build a [`PushDestination`] from a resolved URL.
51pub fn push_destination_for_url(url: &str, relative_base: &Path) -> Result<PushDestination> {
52    let parsed = parse_remote_url(url)?;
53    source_from_parsed(&parsed, relative_base).map(PushDestination::from_concrete)
54}
55
56/// Resolve fetch URL rewriting and transport source in one step.
57pub fn resolve_fetch_source(
58    config: &GitConfig,
59    remote: &str,
60    relative_base: &Path,
61) -> Result<(String, FetchSource)> {
62    let url = fetch_url(config, remote);
63    let source = fetch_source_for_url(&url, relative_base)?;
64    Ok((url, source))
65}
66
67/// Resolve push URL rewriting and transport destination in one step.
68pub fn resolve_push_destination(
69    config: &GitConfig,
70    remote: &str,
71    relative_base: &Path,
72) -> Result<(String, PushDestination)> {
73    let url = push_url(config, remote);
74    let destination = push_destination_for_url(&url, relative_base)?;
75    Ok((url, destination))
76}
77
78enum ConcreteRemote {
79    Network(RemoteUrl),
80    Local {
81        git_dir: PathBuf,
82        common_git_dir: PathBuf,
83    },
84}
85
86impl FetchSource {
87    fn from_concrete(source: ConcreteRemote) -> Self {
88        match source {
89            ConcreteRemote::Network(remote) => match remote.transport {
90                RemoteTransport::Http | RemoteTransport::Https => Self::Http(remote),
91                RemoteTransport::Ssh | RemoteTransport::Ext => Self::Ssh(remote),
92                RemoteTransport::Git => Self::Git {
93                    remote,
94                    protocol_v2: false,
95                },
96                RemoteTransport::Local | RemoteTransport::File => {
97                    unreachable!("local remotes use FetchSource::Local")
98                }
99            },
100            ConcreteRemote::Local {
101                git_dir,
102                common_git_dir,
103            } => Self::Local {
104                git_dir,
105                common_git_dir,
106            },
107        }
108    }
109}
110
111impl PushDestination {
112    fn from_concrete(source: ConcreteRemote) -> Self {
113        match source {
114            ConcreteRemote::Network(remote) => match remote.transport {
115                RemoteTransport::Http | RemoteTransport::Https => Self::Http(remote),
116                RemoteTransport::Ssh | RemoteTransport::Ext => Self::Ssh(remote),
117                RemoteTransport::Git => Self::Git(remote),
118                RemoteTransport::Local | RemoteTransport::File => {
119                    unreachable!("local remotes use PushDestination::Local")
120                }
121            },
122            ConcreteRemote::Local {
123                git_dir,
124                common_git_dir,
125            } => Self::Local {
126                git_dir,
127                common_git_dir,
128            },
129        }
130    }
131}
132
133fn source_from_parsed(parsed: &RemoteUrl, relative_base: &Path) -> Result<ConcreteRemote> {
134    match parsed.transport {
135        RemoteTransport::Http
136        | RemoteTransport::Https
137        | RemoteTransport::Ssh
138        | RemoteTransport::Ext
139        | RemoteTransport::Git => Ok(ConcreteRemote::Network(parsed.clone())),
140        RemoteTransport::Local | RemoteTransport::File => {
141            let repo_path = local_repository_path(parsed, relative_base)?;
142            let git_dir = discover_git_dir(&repo_path)?;
143            Ok(ConcreteRemote::Local {
144                common_git_dir: repository_common_dir(&git_dir),
145                git_dir,
146            })
147        }
148    }
149}
150
151fn local_repository_path(parsed: &RemoteUrl, relative_base: &Path) -> Result<PathBuf> {
152    Ok(match parsed.transport {
153        RemoteTransport::Local => {
154            let path = PathBuf::from(&parsed.path);
155            if path.is_absolute() {
156                path
157            } else {
158                relative_base.join(path)
159            }
160        }
161        RemoteTransport::File => PathBuf::from(&parsed.path),
162        _ => {
163            return Err(GitError::Unsupported("expected a local remote URL".into()));
164        }
165    })
166}
167
168/// Discover the git directory containing `start` (working tree or bare repo).
169fn discover_git_dir(start: &Path) -> Result<PathBuf> {
170    let absolute = if start.is_absolute() {
171        start.to_path_buf()
172    } else {
173        std::env::current_dir().map_err(GitError::from)?.join(start)
174    };
175    for candidate in absolute.ancestors() {
176        let dot_git = candidate.join(".git");
177        if dot_git.is_dir() {
178            return Ok(dot_git);
179        }
180        if dot_git.is_file()
181            && let Some(git_dir) = read_gitdir_link(&dot_git)?
182            && is_git_dir(&git_dir)
183        {
184            return Ok(git_dir);
185        }
186        if is_git_dir(candidate) {
187            return Ok(candidate.to_path_buf());
188        }
189    }
190    Err(GitError::repository_not_found(format!(
191        "not a git repository: {}",
192        start.display()
193    )))
194}
195
196fn is_git_dir(path: &Path) -> bool {
197    path.join("HEAD").is_file()
198        && (path.join("objects").is_dir() || path.join("commondir").is_file())
199}
200
201fn read_gitdir_link(path: &Path) -> Result<Option<PathBuf>> {
202    let contents = std::fs::read_to_string(path)?;
203    let Some(target) = contents.trim().strip_prefix("gitdir:") else {
204        return Ok(None);
205    };
206    let target = PathBuf::from(target.trim());
207    Ok(Some(if target.is_absolute() {
208        target
209    } else {
210        path.parent().unwrap_or_else(|| Path::new("")).join(target)
211    }))
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use sley_config::{ConfigEntry, ConfigSection, GitConfig};
218
219    #[test]
220    fn instead_of_rewrites_fetch_url() {
221        let config = GitConfig {
222            preamble: Vec::new(),
223            suffix: Vec::new(),
224            sections: vec![
225                ConfigSection::new(
226                    "remote",
227                    Some("origin".into()),
228                    vec![ConfigEntry::new(
229                        "url",
230                        Some("git@github.com:org/repo.git".into()),
231                    )],
232                ),
233                ConfigSection::new(
234                    "url",
235                    Some("https://github.com/".into()),
236                    vec![ConfigEntry::new(
237                        "insteadOf",
238                        Some("git@github.com:".into()),
239                    )],
240                ),
241            ],
242        };
243        assert_eq!(
244            fetch_url(&config, "origin"),
245            "https://github.com/org/repo.git"
246        );
247    }
248
249    #[test]
250    fn push_url_prefers_pushurl() {
251        let config = GitConfig {
252            preamble: Vec::new(),
253            suffix: Vec::new(),
254            sections: vec![ConfigSection::new(
255                "remote",
256                Some("origin".into()),
257                vec![
258                    ConfigEntry::new("url", Some("https://fetch.example/x.git".into())),
259                    ConfigEntry::new("pushurl", Some("https://push.example/x.git".into())),
260                ],
261            )],
262        };
263        assert_eq!(push_url(&config, "origin"), "https://push.example/x.git");
264    }
265
266    #[test]
267    fn git_scheme_routes_to_native_git_transport() {
268        let url = "git://127.0.0.1/repo.git";
269
270        assert_eq!(
271            transport_kind_for_url(url).expect("kind"),
272            Some(RemoteTransportKind::Git)
273        );
274        assert!(matches!(
275            fetch_source_for_url(url, Path::new(".")).expect("fetch source"),
276            FetchSource::Git { .. }
277        ));
278        assert!(matches!(
279            push_destination_for_url(url, Path::new(".")).expect("push destination"),
280            PushDestination::Git(_)
281        ));
282    }
283}