Skip to main content

mars_agents/source/
git.rs

1//! Git source adapter — strategy and public API.
2//!
3//! Delegates to `git_cli` for git CLI operations and `archive` for
4//! GitHub archive download/extraction.
5
6use 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/// Options controlling git fetch behavior.
16#[derive(Debug, Clone, Default)]
17pub struct FetchOptions {
18    /// Preferred commit SHA to checkout before resolving tags/versions.
19    /// Used for lock replay to guarantee reproducible content.
20    pub preferred_commit: Option<CommitHash>,
21}
22/// Parse a tag name as a semver version tag.
23///
24/// Accepts: `v1.0.0`, `v0.5.2`, `1.0.0`
25/// Rejects: `latest`, `nightly-2024`, or any non-semver tag.
26pub(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
31/// Return a Git-CLI-fetchable remote URL from a source URL or canonical identity.
32///
33/// Source identities intentionally canonicalize GitHub/GitLab HTTPS URLs to
34/// `host/owner/repo` for equality. Git needs an actual remote locator, so adapt
35/// that shorthand at the boundary before running `git ls-remote`, `clone`, or
36/// `fetch`.
37pub(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
106/// Return true when the URL host resolves to github.com.
107pub 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        // For git clone path, prefer exact SHA checkout when replaying a locked commit,
168        // or when resolving branch/default-HEAD refs (non-tag fetches).
169        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
196/// Fetch a git source at an exact locked commit without resolving a live ref first.
197pub 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    // ==================== parse_semver_tag tests ====================
291
292    #[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        // Seed cache as a shallow tag checkout that does not include future commits.
405        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    // ==================== is_github_host tests ====================
482
483    #[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}