1use crate::diagnostic::DiagnosticCollector;
7use crate::error::MarsError;
8use crate::source::parse::extract_hostname;
9use crate::source::{AvailableVersion, GlobalCache, ResolvedRef};
10use crate::types::CommitHash;
11
12use super::archive;
13use super::git_cli;
14
15#[derive(Debug, Clone, Default)]
17pub struct FetchOptions {
18 pub preferred_commit: Option<CommitHash>,
21}
22pub(crate) fn parse_semver_tag(tag: &str) -> Option<semver::Version> {
27 let version_str = tag.strip_prefix('v').unwrap_or(tag);
28 semver::Version::parse(version_str).ok()
29}
30
31pub(crate) fn normalize_git_remote_url(url: &str) -> String {
38 let trimmed = url.trim();
39 let lower = trimmed.to_ascii_lowercase();
40 if lower.starts_with("github.com/") || lower.starts_with("gitlab.com/") {
41 format!("https://{trimmed}")
42 } else {
43 trimmed.to_string()
44 }
45}
46
47#[derive(Debug, Clone)]
48pub(crate) struct ResolvedVersion {
49 pub tag: Option<String>,
50 pub version: Option<semver::Version>,
51 pub sha: String,
52}
53
54fn resolve_version(
55 url: &str,
56 version_req: Option<&str>,
57 diag: &mut DiagnosticCollector,
58) -> Result<ResolvedVersion, MarsError> {
59 if let Some(version_req) = version_req {
60 if let Some(requested_version) = parse_semver_tag(version_req) {
61 let tags = ls_remote_tags(url)?;
62 let selected = tags
63 .into_iter()
64 .find(|tag| tag.tag == version_req || tag.version == requested_version)
65 .ok_or_else(|| MarsError::Source {
66 source_name: url.to_string(),
67 message: format!("version tag `{version_req}` not found"),
68 })?;
69
70 return Ok(ResolvedVersion {
71 tag: Some(selected.tag),
72 version: Some(selected.version),
73 sha: selected.commit_id,
74 });
75 }
76
77 let sha = ls_remote_ref(url, version_req)?;
78 return Ok(ResolvedVersion {
79 tag: None,
80 version: None,
81 sha,
82 });
83 }
84
85 let tags = ls_remote_tags(url)?;
86 if let Some(selected) = tags.last() {
87 return Ok(ResolvedVersion {
88 tag: Some(selected.tag.clone()),
89 version: Some(selected.version.clone()),
90 sha: selected.commit_id.clone(),
91 });
92 }
93
94 diag.warn(
95 "no-releases",
96 format!("no releases found for {url}, using latest commit from default branch"),
97 );
98 let sha = ls_remote_head(url)?;
99 Ok(ResolvedVersion {
100 tag: None,
101 version: None,
102 sha,
103 })
104}
105
106pub fn is_github_host(url: &str) -> bool {
108 extract_hostname(url)
109 .map(|host| host.eq_ignore_ascii_case("github.com"))
110 .unwrap_or(false)
111}
112
113fn should_use_github_archive(url: &str) -> bool {
114 let trimmed = url.trim();
115 if trimmed.starts_with("git@") || trimmed.starts_with("ssh://") {
116 return false;
117 }
118
119 trimmed.starts_with("https://") && is_github_host(trimmed)
120}
121
122pub fn list_versions(url: &str, _cache: &GlobalCache) -> Result<Vec<AvailableVersion>, MarsError> {
123 ls_remote_tags(url)
124}
125
126fn ls_remote_ref(url: &str, reference: &str) -> Result<String, MarsError> {
127 let remote_url = normalize_git_remote_url(url);
128 git_cli::ls_remote_ref(&remote_url, reference)
129}
130
131pub(crate) fn ls_remote_head(url: &str) -> Result<String, MarsError> {
132 let remote_url = normalize_git_remote_url(url);
133 git_cli::ls_remote_head(&remote_url)
134}
135
136pub fn ls_remote_tags(url: &str) -> Result<Vec<AvailableVersion>, MarsError> {
137 let remote_url = normalize_git_remote_url(url);
138 git_cli::ls_remote_tags(&remote_url)
139}
140
141pub fn fetch(
142 url: &str,
143 version_req: Option<&str>,
144 source_name: &str,
145 cache: &GlobalCache,
146 options: &FetchOptions,
147 diag: &mut DiagnosticCollector,
148) -> Result<ResolvedRef, MarsError> {
149 let remote_url = normalize_git_remote_url(url);
150 let mut resolved = resolve_version(&remote_url, version_req, diag)?;
151 if let Some(preferred_commit) = options.preferred_commit.as_ref() {
152 resolved.sha = preferred_commit.to_string();
153 }
154
155 let tree_path = if should_use_github_archive(&remote_url) {
156 match archive::fetch_archive(&remote_url, &resolved.sha, cache) {
157 Ok(path) => path,
158 Err(MarsError::Http { status: 404, .. }) if options.preferred_commit.is_some() => {
159 return Err(MarsError::LockedCommitUnreachable {
160 commit: resolved.sha.clone(),
161 url: remote_url,
162 });
163 }
164 Err(err) => return Err(err),
165 }
166 } else {
167 let checkout_sha = if options.preferred_commit.is_some() || resolved.tag.is_none() {
170 Some(resolved.sha.as_str())
171 } else {
172 None
173 };
174
175 match git_cli::fetch_git_clone(&remote_url, resolved.tag.as_deref(), checkout_sha, cache) {
176 Ok(path) => path,
177 Err(MarsError::GitCli { .. }) if options.preferred_commit.is_some() => {
178 return Err(MarsError::LockedCommitUnreachable {
179 commit: resolved.sha.clone(),
180 url: remote_url,
181 });
182 }
183 Err(err) => return Err(err),
184 }
185 };
186
187 Ok(ResolvedRef {
188 source_name: source_name.into(),
189 version: resolved.version,
190 version_tag: resolved.tag,
191 commit: Some(CommitHash::from(resolved.sha)),
192 tree_path,
193 })
194}
195
196pub fn fetch_commit(
198 url: &str,
199 commit: &str,
200 source_name: &str,
201 cache: &GlobalCache,
202 _diag: &mut DiagnosticCollector,
203) -> Result<ResolvedRef, MarsError> {
204 let remote_url = normalize_git_remote_url(url);
205 let tree_path = if should_use_github_archive(&remote_url) {
206 match archive::fetch_archive(&remote_url, commit, cache) {
207 Ok(path) => path,
208 Err(MarsError::Http { status: 404, .. }) => {
209 return Err(MarsError::LockedCommitUnreachable {
210 commit: commit.to_string(),
211 url: remote_url,
212 });
213 }
214 Err(err) => return Err(err),
215 }
216 } else {
217 match git_cli::fetch_git_clone(&remote_url, None, Some(commit), cache) {
218 Ok(path) => path,
219 Err(MarsError::GitCli { .. }) => {
220 return Err(MarsError::LockedCommitUnreachable {
221 commit: commit.to_string(),
222 url: remote_url,
223 });
224 }
225 Err(err) => return Err(err),
226 }
227 };
228
229 Ok(ResolvedRef {
230 source_name: source_name.into(),
231 version: None,
232 version_tag: None,
233 commit: Some(CommitHash::from(commit)),
234 tree_path,
235 })
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use semver::Version;
242 use std::ffi::OsStr;
243 use std::fs;
244 use std::path::Path;
245 use std::process::Command;
246 use tempfile::TempDir;
247
248 fn run_git<I, S>(cwd: &Path, args: I) -> String
249 where
250 I: IntoIterator<Item = S>,
251 S: AsRef<OsStr>,
252 {
253 let mut command = Command::new("git");
254 crate::platform::process::remove_git_local_env(&mut command);
255 command.env("GIT_AUTHOR_NAME", "Mars Test");
256 command.env("GIT_AUTHOR_EMAIL", "mars@example.com");
257 command.env("GIT_COMMITTER_NAME", "Mars Test");
258 command.env("GIT_COMMITTER_EMAIL", "mars@example.com");
259 let output = command.current_dir(cwd).args(args).output().unwrap();
260 if !output.status.success() {
261 panic!(
262 "git command failed: {}\nstdout:\n{}\nstderr:\n{}",
263 output.status,
264 String::from_utf8_lossy(&output.stdout),
265 String::from_utf8_lossy(&output.stderr)
266 );
267 }
268 String::from_utf8_lossy(&output.stdout).trim().to_string()
269 }
270
271 fn init_repo() -> TempDir {
272 let repo = TempDir::new().unwrap();
273 run_git(repo.path(), ["init", "."]);
274 run_git(repo.path(), ["config", "user.name", "Mars Test"]);
275 run_git(repo.path(), ["config", "user.email", "mars@example.com"]);
276
277 fs::write(repo.path().join("README.md"), "initial\n").unwrap();
278 run_git(repo.path(), ["add", "."]);
279 run_git(repo.path(), ["commit", "-m", "initial commit"]);
280
281 repo
282 }
283
284 fn commit_file(repo: &Path, filename: &str, contents: &str, message: &str) -> String {
285 fs::write(repo.join(filename), contents).unwrap();
286 run_git(repo, ["add", filename]);
287 run_git(repo, ["commit", "-m", message]);
288 run_git(repo, ["rev-parse", "HEAD"])
289 }
290 #[test]
293 fn parse_semver_v_prefixed() {
294 let v = parse_semver_tag("v1.2.3").unwrap();
295 assert_eq!(v, semver::Version::new(1, 2, 3));
296 }
297
298 #[test]
299 fn parse_semver_no_prefix() {
300 let v = parse_semver_tag("0.5.2").unwrap();
301 assert_eq!(v, semver::Version::new(0, 5, 2));
302 }
303
304 #[test]
305 fn ls_remote_tags_filters_sorts_and_skips_peeled_refs() {
306 let repo = init_repo();
307 run_git(repo.path(), ["tag", "v1.0.0"]);
308
309 commit_file(repo.path(), "README.md", "second\n", "second commit");
310 run_git(repo.path(), ["tag", "-a", "v1.2.0", "-m", "v1.2.0"]);
311 run_git(repo.path(), ["tag", "not-a-version"]);
312
313 commit_file(repo.path(), "README.md", "third\n", "third commit");
314 run_git(repo.path(), ["tag", "v1.10.0"]);
315
316 let versions = ls_remote_tags(repo.path().to_str().unwrap()).unwrap();
317 let tags: Vec<String> = versions.iter().map(|v| v.tag.clone()).collect();
318 assert_eq!(tags, vec!["v1.0.0", "v1.2.0", "v1.10.0"]);
319
320 for version in versions {
321 assert_eq!(version.commit_id.len(), 40);
322 assert!(version.commit_id.chars().all(|c| c.is_ascii_hexdigit()));
323 }
324 }
325
326 #[test]
327 fn fetch_local_git_repo_uses_latest_semver_tag() {
328 let remote = init_repo();
329 run_git(remote.path(), ["tag", "v0.1.0"]);
330
331 let v020_commit = commit_file(remote.path(), "README.md", "v0.2.0\n", "release v0.2.0");
332 run_git(remote.path(), ["tag", "v0.2.0"]);
333
334 let cache_root = TempDir::new().unwrap();
335 let cache = GlobalCache {
336 root: cache_root.path().join("cache"),
337 };
338 fs::create_dir_all(cache.archives_dir()).unwrap();
339 fs::create_dir_all(cache.git_dir()).unwrap();
340
341 let url = format!("file://{}", remote.path().display());
342 let mut diag = DiagnosticCollector::new();
343 let resolved = fetch(
344 &url,
345 None,
346 "local-source",
347 &cache,
348 &FetchOptions::default(),
349 &mut diag,
350 )
351 .unwrap();
352
353 assert_eq!(resolved.source_name.as_ref(), "local-source");
354 assert_eq!(resolved.version, Some(Version::new(0, 2, 0)));
355 assert_eq!(resolved.version_tag.as_deref(), Some("v0.2.0"));
356 assert_eq!(resolved.commit.as_deref(), Some(v020_commit.as_str()));
357 assert!(resolved.tree_path.join("README.md").exists());
358
359 let checked_out = run_git(&resolved.tree_path, ["rev-parse", "HEAD"]);
360 assert_eq!(checked_out, v020_commit);
361 }
362
363 #[test]
364 fn fetch_commit_checks_out_exact_commit_without_resolving_head() {
365 let remote = init_repo();
366 let locked_commit = commit_file(remote.path(), "README.md", "locked\n", "locked commit");
367 let head_commit = commit_file(remote.path(), "README.md", "head\n", "head commit");
368 assert_ne!(locked_commit, head_commit);
369
370 let cache_root = TempDir::new().unwrap();
371 let cache = GlobalCache {
372 root: cache_root.path().join("cache"),
373 };
374 fs::create_dir_all(cache.archives_dir()).unwrap();
375 fs::create_dir_all(cache.git_dir()).unwrap();
376
377 let url = format!("file://{}", remote.path().display());
378 let mut diag = DiagnosticCollector::new();
379 let resolved =
380 fetch_commit(&url, &locked_commit, "local-source", &cache, &mut diag).unwrap();
381
382 assert_eq!(resolved.source_name.as_ref(), "local-source");
383 assert_eq!(resolved.version, None);
384 assert_eq!(resolved.version_tag, None);
385 assert_eq!(resolved.commit.as_deref(), Some(locked_commit.as_str()));
386 let checked_out = run_git(&resolved.tree_path, ["rev-parse", "HEAD"]);
387 assert_eq!(checked_out, locked_commit);
388 }
389
390 #[test]
391 fn fetch_commit_on_cached_repo_fetches_missing_sha_before_checkout() {
392 let remote = init_repo();
393 run_git(remote.path(), ["tag", "v1.0.0"]);
394
395 let cache_root = TempDir::new().unwrap();
396 let cache = GlobalCache {
397 root: cache_root.path().join("cache"),
398 };
399 fs::create_dir_all(cache.archives_dir()).unwrap();
400 fs::create_dir_all(cache.git_dir()).unwrap();
401
402 let url = format!("file://{}", remote.path().display());
403
404 let mut first_diag = DiagnosticCollector::new();
406 let first = fetch(
407 &url,
408 Some("v1.0.0"),
409 "local-source",
410 &cache,
411 &FetchOptions::default(),
412 &mut first_diag,
413 )
414 .unwrap();
415 assert_eq!(first.version_tag.as_deref(), Some("v1.0.0"));
416
417 let locked_commit = commit_file(
418 remote.path(),
419 "README.md",
420 "post-tag\n",
421 "commit only reachable by SHA",
422 );
423
424 let mut diag = DiagnosticCollector::new();
425 let resolved =
426 fetch_commit(&url, &locked_commit, "local-source", &cache, &mut diag).unwrap();
427 assert_eq!(resolved.commit.as_deref(), Some(locked_commit.as_str()));
428 let checked_out = run_git(&resolved.tree_path, ["rev-parse", "HEAD"]);
429 assert_eq!(checked_out, locked_commit);
430 }
431
432 #[test]
433 fn fetch_existing_cached_git_repo_updates_tags_before_checkout() {
434 let remote = init_repo();
435 run_git(remote.path(), ["tag", "v1.0.0"]);
436
437 let cache_root = TempDir::new().unwrap();
438 let cache = GlobalCache {
439 root: cache_root.path().join("cache"),
440 };
441 fs::create_dir_all(cache.archives_dir()).unwrap();
442 fs::create_dir_all(cache.git_dir()).unwrap();
443
444 let url = format!("file://{}", remote.path().display());
445
446 let mut first_diag = DiagnosticCollector::new();
447 let first = fetch(
448 &url,
449 None,
450 "local-source",
451 &cache,
452 &FetchOptions::default(),
453 &mut first_diag,
454 )
455 .unwrap();
456 assert_eq!(first.version, Some(Version::new(1, 0, 0)));
457 assert_eq!(first.version_tag.as_deref(), Some("v1.0.0"));
458
459 let v200_commit = commit_file(remote.path(), "README.md", "v2.0.0\n", "release v2.0.0");
460 run_git(remote.path(), ["tag", "v2.0.0"]);
461
462 let mut second_diag = DiagnosticCollector::new();
463 let second = fetch(
464 &url,
465 None,
466 "local-source",
467 &cache,
468 &FetchOptions::default(),
469 &mut second_diag,
470 )
471 .unwrap();
472
473 assert_eq!(second.version, Some(Version::new(2, 0, 0)));
474 assert_eq!(second.version_tag.as_deref(), Some("v2.0.0"));
475 assert_eq!(second.commit.as_deref(), Some(v200_commit.as_str()));
476
477 let checked_out = run_git(&second.tree_path, ["rev-parse", "HEAD"]);
478 assert_eq!(checked_out, v200_commit);
479 }
480
481 #[test]
484 fn is_github_host_accepts_supported_formats() {
485 assert!(is_github_host("https://github.com/org/repo"));
486 assert!(is_github_host("github.com/org/repo"));
487 assert!(is_github_host("git@github.com:org/repo.git"));
488 assert!(is_github_host("https://git@github.com:8443/org/repo"));
489 }
490
491 #[test]
492 fn is_github_host_rejects_other_hosts() {
493 assert!(!is_github_host("https://gitlab.com/org/repo"));
494 assert!(!is_github_host("git@source.example.com:org/repo.git"));
495 }
496
497 #[test]
498 fn normalize_git_remote_url_makes_known_host_identity_fetchable() {
499 assert_eq!(
500 normalize_git_remote_url("github.com/org/repo"),
501 "https://github.com/org/repo"
502 );
503 assert_eq!(
504 normalize_git_remote_url("gitlab.com/group/repo"),
505 "https://gitlab.com/group/repo"
506 );
507 }
508
509 #[test]
510 fn normalize_git_remote_url_preserves_explicit_locators() {
511 assert_eq!(
512 normalize_git_remote_url("https://github.com/org/repo"),
513 "https://github.com/org/repo"
514 );
515 assert_eq!(
516 normalize_git_remote_url("git@github.com:org/repo.git"),
517 "git@github.com:org/repo.git"
518 );
519 assert_eq!(
520 normalize_git_remote_url("git.example.com/org/repo"),
521 "git.example.com/org/repo"
522 );
523 }
524
525 #[test]
526 fn github_archive_only_for_https_github_urls() {
527 assert!(should_use_github_archive("https://github.com/org/repo"));
528 assert!(!should_use_github_archive("http://github.com/org/repo"));
529 assert!(!should_use_github_archive("github.com/org/repo"));
530 assert!(!should_use_github_archive("git@github.com:org/repo.git"));
531 assert!(!should_use_github_archive("ssh://git@github.com/org/repo"));
532 }
533}