Skip to main content

mars_agents/source/
git_cli.rs

1//! Git CLI operations — ls-remote, clone, fetch, checkout.
2
3use std::path::{Path, PathBuf};
4
5use crate::error::MarsError;
6use crate::platform::cache::git_cache_component;
7use crate::platform::process::{display_command, run_git, run_git_with_ref};
8use crate::source::{AvailableVersion, GlobalCache};
9
10use super::git::parse_semver_tag;
11
12pub(crate) fn ls_remote_ref(url: &str, reference: &str) -> Result<String, MarsError> {
13    let command_display = display_command(&["ls-remote", url, reference]);
14    let output = run_git_with_ref(
15        &["ls-remote", url],
16        reference,
17        Path::new("."),
18        "resolve remote git reference",
19    )?;
20
21    for line in output.lines() {
22        if let Some((sha, _)) = line.split_once('\t')
23            && !sha.trim().is_empty()
24        {
25            return Ok(sha.trim().to_string());
26        }
27    }
28
29    Err(MarsError::GitCli {
30        command: command_display,
31        message: format!("reference `{reference}` not found"),
32    })
33}
34
35/// Run `git ls-remote --tags <url>` and parse semver tags.
36pub fn ls_remote_tags(url: &str) -> Result<Vec<AvailableVersion>, MarsError> {
37    let output = run_git(
38        &["ls-remote", "--tags", url],
39        Path::new("."),
40        "list remote git tags",
41    )?;
42    let mut versions = Vec::new();
43
44    for line in output.lines() {
45        let Some((sha, reference)) = line.split_once('\t') else {
46            continue;
47        };
48        let Some(tag) = reference.strip_prefix("refs/tags/") else {
49            continue;
50        };
51
52        // Annotated tags show up twice (`tag` and peeled `tag^{}`).
53        // Keep only the non-peeled entry to avoid duplicates.
54        if tag.ends_with("^{}") {
55            continue;
56        }
57
58        let Some(version) = parse_semver_tag(tag) else {
59            continue;
60        };
61
62        versions.push(AvailableVersion {
63            tag: tag.to_string(),
64            version,
65            commit_id: sha.trim().to_string(),
66        });
67    }
68
69    versions.sort_by(|a, b| a.version.cmp(&b.version));
70    Ok(versions)
71}
72
73/// Run `git ls-remote <url> HEAD` and return the default-branch SHA.
74pub fn ls_remote_head(url: &str) -> Result<String, MarsError> {
75    ls_remote_ref(url, "HEAD")
76}
77
78pub(crate) fn fetch_git_clone(
79    url: &str,
80    tag: Option<&str>,
81    sha: Option<&str>,
82    cache: &GlobalCache,
83) -> Result<PathBuf, MarsError> {
84    let cache_name = git_cache_component(url)?;
85    let cache_path = cache.git_dir().join(cache_name);
86
87    // Acquire per-entry lock to prevent cross-repo races on the same cache entry.
88    // Held through fetch + checkout, released when _lock drops at function return.
89    let lock_path = cache_path.with_extension("lock");
90    let _lock = crate::fs::FileLock::acquire(&lock_path)?;
91
92    let cache_path_display = cache_path.to_string_lossy().to_string();
93    let was_cached = cache_path.exists();
94
95    if !was_cached {
96        let mut args = vec!["clone"];
97        if sha.is_none() {
98            args.push("--depth");
99            args.push("1");
100        }
101        if let Some(tag_name) = tag {
102            args.push("--branch");
103            args.push(tag_name);
104        }
105        args.push(url);
106        args.push(&cache_path_display);
107
108        run_git(&args, &cache.git_dir(), "clone git source into cache")?;
109    } else {
110        run_git(
111            &["fetch", "--depth", "1", "--tags", "--prune-tags", "origin"],
112            &cache_path,
113            "fetch cached git source",
114        )?;
115    }
116
117    if was_cached {
118        if let Some(sha) = sha {
119            match run_git(
120                &["fetch", "--depth", "1", "origin", sha],
121                &cache_path,
122                "fetch cached git commit",
123            ) {
124                Ok(_) => {}
125                Err(_) => {
126                    run_git(
127                        &["fetch", "--unshallow", "origin"],
128                        &cache_path,
129                        "unshallow cached git source for locked commit",
130                    )
131                    .or_else(|_| {
132                        run_git(
133                            &["fetch", "origin"],
134                            &cache_path,
135                            "fetch cached git source for locked commit",
136                        )
137                    })?;
138                }
139            }
140        }
141
142        if let Some(tag_name) = tag {
143            run_git(
144                &["checkout", tag_name],
145                &cache_path,
146                "checkout cached git tag",
147            )?;
148        }
149
150        if let Some(sha) = sha {
151            run_git(
152                &["checkout", sha],
153                &cache_path,
154                "checkout cached git commit",
155            )?;
156        } else if tag.is_none() {
157            run_git(
158                &["checkout", "origin/HEAD"],
159                &cache_path,
160                "checkout cached git default head",
161            )?;
162        }
163    } else if let Some(sha) = sha {
164        run_git(
165            &["checkout", sha],
166            &cache_path,
167            "checkout cloned git commit",
168        )?;
169    }
170
171    Ok(cache_path)
172}