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