1use std::path::Path;
4
5use url::Url;
6
7const TRANSPORT_SCHEMES: [&str; 6] = ["file", "git", "git+ssh", "http", "https", "ssh"];
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum GitRemoteKind {
13 Url,
16 Scp,
18 Path,
20}
21
22pub fn git_remote_kind(remote: &str) -> GitRemoteKind {
24 if transport_url(remote).is_some() {
25 GitRemoteKind::Url
26 } else if scp_parts(remote).is_some() {
27 GitRemoteKind::Scp
28 } else {
29 GitRemoteKind::Path
30 }
31}
32
33pub fn normalize_git_remote(remote: &str) -> Option<Url> {
55 let path = Path::new(remote);
56 if path.is_absolute() {
57 return Url::from_file_path(path).ok();
58 }
59 if let Some(url) = transport_url(remote) {
60 return Some(url);
61 }
62 if let Some((host, path)) = scp_parts(remote) {
63 let path = path.strip_prefix('/').unwrap_or(path);
66 return Url::parse(&format!("ssh://{host}/{path}")).ok();
67 }
68 Url::from_file_path(path.canonicalize().ok()?).ok()
69}
70
71fn transport_url(remote: &str) -> Option<Url> {
77 let url = Url::parse(remote).ok()?;
78 TRANSPORT_SCHEMES.contains(&url.scheme()).then_some(url)
79}
80
81fn scp_parts(remote: &str) -> Option<(&str, &str)> {
87 if starts_with_windows_drive(remote) {
88 return None;
89 }
90 let (host, path) = remote.split_once(':')?;
91 (!host.is_empty() && !host.contains(['/', '\\']) && !path.is_empty() && !path.starts_with("//"))
92 .then_some((host, path))
93}
94
95fn starts_with_windows_drive(remote: &str) -> bool {
98 let mut bytes = remote.bytes();
99 matches!(
100 (bytes.next(), bytes.next()),
101 (Some(b'A'..=b'Z' | b'a'..=b'z'), Some(b':'))
102 )
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 fn normalized(remote: &str) -> Option<String> {
110 normalize_git_remote(remote).map(|url| url.to_string())
111 }
112
113 #[test]
114 fn scp_syntax_becomes_an_ssh_url() {
115 assert_eq!(
116 normalized("git@github.com:stjudecloud/workflows.git").as_deref(),
117 Some("ssh://git@github.com/stjudecloud/workflows.git")
118 );
119 }
120
121 #[test]
122 fn scp_syntax_without_a_user_becomes_an_ssh_url() {
123 assert_eq!(
124 normalized("github.com:stjudecloud/workflows.git").as_deref(),
125 Some("ssh://github.com/stjudecloud/workflows.git")
126 );
127 }
128
129 #[test]
130 fn a_server_absolute_scp_path_keeps_one_separator() {
131 assert_eq!(
132 normalized("git@example.com:/srv/git/workflows.git").as_deref(),
133 Some("ssh://git@example.com/srv/git/workflows.git")
134 );
135 }
136
137 #[test]
138 fn transport_urls_pass_through_unchanged() {
139 assert_eq!(
140 normalized("https://github.com/stjudecloud/workflows.git").as_deref(),
141 Some("https://github.com/stjudecloud/workflows.git")
142 );
143 }
144
145 #[test]
146 fn absolute_paths_become_file_urls() {
147 let remote = if cfg!(windows) {
148 "C:\\repos\\workflows"
149 } else {
150 "/repos/workflows"
151 };
152
153 let normalized = normalized(remote);
154
155 assert!(
156 normalized
157 .as_deref()
158 .is_some_and(|url| url.starts_with("file://")),
159 "expected a file URL, got {normalized:?}"
160 );
161 }
162
163 #[test]
164 fn relative_paths_that_do_not_exist_are_rejected() {
165 assert_eq!(normalized("relative/path-that-does-not-exist"), None);
166 }
167
168 #[test]
169 fn scp_syntax_does_not_swallow_a_windows_drive_path() {
170 assert_eq!(git_remote_kind("C:\\repos\\workflows"), GitRemoteKind::Path);
171 }
172
173 #[test]
174 fn a_bare_host_and_path_is_scp_syntax_rather_than_a_url() {
175 assert_eq!(
176 git_remote_kind("github.com:stjudecloud/workflows.git"),
177 GitRemoteKind::Scp
178 );
179 }
180}