agentpack/
git.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::Context as _;
5
6pub fn resolve_git_ref(url: &str, ref_name: &str) -> anyhow::Result<String> {
7    if is_hex_sha(ref_name) {
8        return Ok(ref_name.to_string());
9    }
10
11    let patterns = [
12        format!("refs/heads/{ref_name}"),
13        format!("refs/tags/{ref_name}"),
14        format!("refs/tags/{ref_name}^{{}}"),
15    ];
16
17    let mut cmd = Command::new("git");
18    cmd.arg("ls-remote").arg(url);
19    for p in &patterns {
20        cmd.arg(p);
21    }
22
23    let out = cmd.output().context("git ls-remote")?;
24    if !out.status.success() {
25        anyhow::bail!(
26            "git ls-remote failed: {}",
27            String::from_utf8_lossy(&out.stderr)
28        );
29    }
30
31    let stdout = String::from_utf8(out.stdout).context("decode git ls-remote output")?;
32    let mut peeled: Option<String> = None;
33    let mut direct: Option<String> = None;
34
35    for line in stdout.lines() {
36        let mut parts = line.split_whitespace();
37        let sha = parts.next().unwrap_or_default();
38        let r = parts.next().unwrap_or_default();
39        if sha.is_empty() || r.is_empty() {
40            continue;
41        }
42        if r.ends_with("^{}") {
43            peeled = Some(sha.to_string());
44        } else {
45            direct.get_or_insert_with(|| sha.to_string());
46        }
47    }
48
49    peeled
50        .or(direct)
51        .with_context(|| format!("ref not found: {ref_name}"))
52}
53
54pub fn clone_checkout_git(
55    url: &str,
56    ref_name: &str,
57    commit: &str,
58    dest_dir: &Path,
59    shallow: bool,
60) -> anyhow::Result<()> {
61    if dest_dir.exists() {
62        return Ok(());
63    }
64
65    let tmp_dir = dest_dir.with_extension("tmp");
66
67    let try_clone_checkout = |use_shallow: bool| -> anyhow::Result<()> {
68        if tmp_dir.exists() {
69            std::fs::remove_dir_all(&tmp_dir).ok();
70        }
71
72        let mut clone = Command::new("git");
73        clone.arg("clone");
74        if use_shallow && !is_hex_sha(ref_name) {
75            clone.arg("--depth").arg("1").arg("--branch").arg(ref_name);
76        }
77        clone.arg(url).arg(&tmp_dir);
78        let out = clone.output().context("git clone")?;
79        if !out.status.success() {
80            anyhow::bail!("git clone failed: {}", String::from_utf8_lossy(&out.stderr));
81        }
82
83        let mut checkout = Command::new("git");
84        checkout.current_dir(&tmp_dir).arg("checkout").arg(commit);
85        let out = checkout.output().context("git checkout")?;
86        if !out.status.success() {
87            anyhow::bail!(
88                "git checkout failed: {}",
89                String::from_utf8_lossy(&out.stderr)
90            );
91        }
92
93        Ok(())
94    };
95
96    let shallow_attempt = shallow && !is_hex_sha(ref_name);
97    match try_clone_checkout(shallow_attempt) {
98        Ok(()) => {}
99        Err(err) if shallow_attempt => {
100            let first_err = err.to_string();
101            try_clone_checkout(false).with_context(|| {
102                format!(
103                    "shallow clone/checkout failed (retrying non-shallow); if this persists, set shallow=false in the module source: {first_err}"
104                )
105            })?;
106        }
107        Err(err) => return Err(err),
108    }
109
110    std::fs::rename(&tmp_dir, dest_dir).context("finalize git checkout")?;
111    Ok(())
112}
113
114pub fn git_in(cwd: &Path, args: &[&str]) -> anyhow::Result<String> {
115    let out = Command::new("git").current_dir(cwd).args(args).output();
116    let out = match out {
117        Ok(out) => out,
118        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
119            return Err(crate::user_error::UserError::git_not_found(cwd, args));
120        }
121        Err(err) => return Err(err).with_context(|| format!("git {args:?}")),
122    };
123    if !out.status.success() {
124        anyhow::bail!(
125            "git {:?} failed: {}",
126            args,
127            String::from_utf8_lossy(&out.stderr)
128        );
129    }
130    String::from_utf8(out.stdout).context("decode git output")
131}
132
133fn is_hex_sha(s: &str) -> bool {
134    if s.len() != 40 {
135        return false;
136    }
137    s.chars().all(|c| c.is_ascii_hexdigit())
138}