1pub(crate) fn normalize_git_url(raw: &str) -> String {
18 let mut s = raw.trim().to_string();
19
20 if let Some(stripped) = s.strip_suffix('/') {
21 s = stripped.to_string();
22 }
23
24 if let Some(stripped) = s.strip_suffix(".git") {
25 s = stripped.to_string();
26 }
27
28 if !s.contains("://")
30 && s.contains('@')
31 && let Some(colon) = s.find(':')
32 {
33 let mut rewritten = s.clone();
34 rewritten.replace_range(colon..=colon, "/");
35 s = format!("ssh://{rewritten}");
36 }
37
38 match url::Url::parse(&s) {
39 Ok(url) if url.host_str().is_some_and(|host| !host.is_empty()) => {
40 let host = url.host_str().unwrap_or_default();
41 let path = url.path().trim_start_matches('/');
42 format!("{host}/{path}").to_lowercase()
43 }
44 _ => s.to_lowercase(),
45 }
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 struct NormalizeCase {
55 name: &'static str,
56 url: &'static str,
57 want: &'static str,
58 }
59
60 #[test]
61 fn normalize_git_url_cases() {
62 let cases = [
63 NormalizeCase {
64 name: "https with .git suffix",
65 url: "https://gitlab.com/gitlab-org/gitlab.git",
66 want: "gitlab.com/gitlab-org/gitlab",
67 },
68 NormalizeCase {
69 name: "https without .git suffix",
70 url: "https://gitlab.com/gitlab-org/gitlab",
71 want: "gitlab.com/gitlab-org/gitlab",
72 },
73 NormalizeCase {
74 name: "https with trailing slash",
75 url: "https://gitlab.com/gitlab-org/gitlab/",
76 want: "gitlab.com/gitlab-org/gitlab",
77 },
78 NormalizeCase {
79 name: "scp-style ssh",
80 url: "git@gitlab.com:gitlab-org/gitlab.git",
81 want: "gitlab.com/gitlab-org/gitlab",
82 },
83 NormalizeCase {
84 name: "ssh protocol",
85 url: "ssh://git@gitlab.com/gitlab-org/gitlab.git",
86 want: "gitlab.com/gitlab-org/gitlab",
87 },
88 NormalizeCase {
89 name: "uppercase normalized to lowercase",
90 url: "https://GitLab.com/GitLab-Org/GitLab.git",
91 want: "gitlab.com/gitlab-org/gitlab",
92 },
93 NormalizeCase {
94 name: "host with port",
95 url: "https://gitlab.example.com:8080/gitlab-org/gitlab.git",
96 want: "gitlab.example.com/gitlab-org/gitlab",
97 },
98 NormalizeCase {
99 name: "whitespace trimmed",
100 url: " https://gitlab.com/gitlab-org/gitlab.git ",
101 want: "gitlab.com/gitlab-org/gitlab",
102 },
103 NormalizeCase {
104 name: "authenticated https drops credentials",
105 url: "https://user:abc@gitlab.com/gitlab-org/gitlab.git",
106 want: "gitlab.com/gitlab-org/gitlab",
107 },
108 NormalizeCase {
109 name: "different hosts produce different output",
110 url: "https://github.com/gitlab-org/gitlab.git",
111 want: "github.com/gitlab-org/gitlab",
112 },
113 ];
114
115 for case in cases {
116 assert_eq!(normalize_git_url(case.url), case.want, "{}", case.name);
117 }
118 }
119
120 struct SameRepoCase {
123 name: &'static str,
124 a: &'static str,
125 b: &'static str,
126 want_match: bool,
127 }
128
129 #[test]
130 fn normalize_git_url_same_repo_matches_cases() {
131 let cases = [
132 SameRepoCase {
133 name: "https configured, ssh local",
134 a: "https://gitlab.com/gitlab-org/gitlab.git",
135 b: "git@gitlab.com:gitlab-org/gitlab.git",
136 want_match: true,
137 },
138 SameRepoCase {
139 name: "trailing slash vs none",
140 a: "https://gitlab.com/gitlab-org/gitlab/",
141 b: "https://gitlab.com/gitlab-org/gitlab",
142 want_match: true,
143 },
144 SameRepoCase {
145 name: ".git suffix vs none",
146 a: "https://gitlab.com/gitlab-org/gitlab.git",
147 b: "https://gitlab.com/gitlab-org/gitlab",
148 want_match: true,
149 },
150 SameRepoCase {
151 name: "different repos do not match",
152 a: "https://gitlab.com/gitlab-org/gitlab.git",
153 b: "https://gitlab.com/gitlab-org/other.git",
154 want_match: false,
155 },
156 SameRepoCase {
157 name: "same path on different hosts does not match",
158 a: "https://gitlab.com/gitlab-org/gitlab.git",
159 b: "https://github.com/gitlab-org/gitlab.git",
160 want_match: false,
161 },
162 SameRepoCase {
163 name: "authenticated and unauthenticated https match",
164 a: "https://user:abc@gitlab.com/gitlab-org/gitlab.git",
165 b: "https://gitlab.com/gitlab-org/gitlab.git",
166 want_match: true,
167 },
168 ];
169
170 for case in cases {
171 assert_eq!(
172 normalize_git_url(case.a) == normalize_git_url(case.b),
173 case.want_match,
174 "{}",
175 case.name
176 );
177 }
178 }
179
180 use std::process::Command;
190 use std::sync::Arc;
191
192 use crate::git::{Backend, Client};
193
194 fn git_in(dir: &std::path::Path, args: &[&str]) {
195 let status = Command::new("git")
196 .arg("-C")
197 .arg(dir)
198 .args(args)
199 .status()
200 .expect("git binary must be on PATH");
201 assert!(status.success(), "git {args:?} failed in {dir:?}");
202 }
203
204 fn init_repo_with_commit(dir: &std::path::Path) {
205 std::fs::create_dir_all(dir).expect("create repo directory");
206 git_in(dir, &["init", "--quiet", "--initial-branch=main"]);
207 git_in(dir, &["config", "user.email", "test@example.com"]);
208 git_in(dir, &["config", "user.name", "Test User"]);
209 std::fs::write(dir.join("README.md"), "hello\n").expect("write seed file");
210 git_in(dir, &["add", "README.md"]);
211 git_in(dir, &["commit", "--quiet", "-m", "initial commit"]);
212 }
213
214 fn client() -> Client {
215 Client::new(Arc::new(xianyun_executor::exec::Client::new()))
216 }
217
218 #[test]
219 fn detect_state_non_existent_directory() {
220 let temp = tempfile::tempdir().expect("create tempdir");
221 let missing = temp.path().join("does-not-exist");
222
223 let state = client()
224 .detect_state(missing.to_str().unwrap(), "")
225 .expect("detect_state is fail-safe and never errors");
226
227 assert!(!state.exists);
228 assert!(!state.is_git_repo);
229 assert!(state.last_error.is_none());
230 }
231
232 #[test]
233 fn detect_state_not_git_repository() {
234 let temp = tempfile::tempdir().expect("create tempdir");
235 std::fs::write(temp.path().join("plain.txt"), "not a repo\n").expect("write plain file");
236
237 let state = client()
238 .detect_state(temp.path().to_str().unwrap(), "")
239 .expect("detect_state is fail-safe and never errors");
240
241 assert!(state.exists);
242 assert!(!state.is_git_repo);
243 }
244
245 #[test]
246 fn detect_state_clean_git_repository() {
247 let temp = tempfile::tempdir().expect("create tempdir");
248 let remote_dir = temp.path().join("remote");
249 init_repo_with_commit(&remote_dir);
250
251 let clone_dir = temp.path().join("clone");
252 let remote_url = format!("file://{}", remote_dir.display());
253 git_in(
254 temp.path(),
255 &["clone", "--quiet", &remote_url, clone_dir.to_str().unwrap()],
256 );
257
258 let state = client()
259 .detect_state(clone_dir.to_str().unwrap(), &remote_url)
260 .expect("detect_state is fail-safe and never errors");
261
262 assert!(state.exists);
263 assert!(state.is_git_repo);
264 assert!(!state.has_uncommitted);
265 assert!(state.remote_matches);
266 assert!(!state.remote_url.is_empty());
267 assert!(state.last_error.is_none());
268 }
269
270 #[test]
271 fn detect_state_uncommitted_changes() {
272 let temp = tempfile::tempdir().expect("create tempdir");
273 let remote_dir = temp.path().join("remote");
274 init_repo_with_commit(&remote_dir);
275
276 let clone_dir = temp.path().join("clone");
277 let remote_url = format!("file://{}", remote_dir.display());
278 git_in(
279 temp.path(),
280 &["clone", "--quiet", &remote_url, clone_dir.to_str().unwrap()],
281 );
282
283 std::fs::write(clone_dir.join("README.md"), "modified\n").expect("modify tracked file");
284
285 let state = client()
286 .detect_state(clone_dir.to_str().unwrap(), &remote_url)
287 .expect("detect_state is fail-safe and never errors");
288
289 assert!(state.exists);
290 assert!(state.is_git_repo);
291 assert!(state.has_uncommitted);
292 }
293
294 #[test]
295 fn checkout_pins_to_commit() {
296 let temp = tempfile::tempdir().expect("create tempdir");
297 let repo_dir = temp.path().join("repo");
298 init_repo_with_commit(&repo_dir);
299
300 let first_commit = client()
301 .get_revision(repo_dir.to_str().unwrap())
302 .expect("get first revision");
303
304 std::fs::write(repo_dir.join("README.md"), "second\n").expect("write second revision");
305 git_in(&repo_dir, &["add", "README.md"]);
306 git_in(&repo_dir, &["commit", "--quiet", "-m", "second commit"]);
307
308 client()
309 .checkout(repo_dir.to_str().unwrap(), &first_commit)
310 .expect("checkout the pinned commit");
311
312 let revision_after_checkout = client()
313 .get_revision(repo_dir.to_str().unwrap())
314 .expect("get revision after checkout");
315
316 assert_eq!(revision_after_checkout, first_commit);
317
318 let contents =
319 std::fs::read_to_string(repo_dir.join("README.md")).expect("read checked-out file");
320 assert_eq!(contents, "hello\n");
321 }
322}