Skip to main content

mars_agents/source/
git_cli.rs

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