Skip to main content

leo_package/
git.rs

1// Copyright (C) 2019-2026 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17//! Fetching and checking out git dependencies.
18//!
19//! A git dependency resolves to an exact commit, checked out into a content-addressed cache
20//! (`<home>/git/checkouts/<repo>-<urlhash>/<commit>/`) and then treated as a local package.
21
22use crate::GitReference;
23
24use snarkvm::algorithms::crypto_hash::sha256;
25
26use leo_errors::Result;
27
28use std::{
29    path::{Path, PathBuf},
30    sync::atomic::{AtomicU64, Ordering},
31};
32
33const CHECKOUTS_SUBDIR: &str = "git/checkouts";
34const TMP_SUBDIR: &str = "git/tmp";
35
36/// The root of the git checkout cache under the Leo home directory.
37pub(crate) fn checkouts_root(home: &Path) -> PathBuf {
38    home.join(CHECKOUTS_SUBDIR)
39}
40
41/// The directory a given commit is checked out into, keyed by URL hash and commit so all
42/// dependencies into the same repository share one checkout.
43pub(crate) fn checkout_dir(home: &Path, url: &str, commit: &str) -> PathBuf {
44    let digest = sha256(url.as_bytes());
45    let url_hash: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
46    // A human-readable prefix from the URL's last path segment; uniqueness comes from the hash.
47    let repo: String = url
48        .trim_end_matches('/')
49        .rsplit('/')
50        .next()
51        .unwrap_or(url)
52        .trim_end_matches(".git")
53        .chars()
54        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
55        .take(32)
56        .collect();
57    let repo = if repo.is_empty() { "repo".to_string() } else { repo };
58    checkouts_root(home).join(format!("{repo}-{url_hash}")).join(commit)
59}
60
61/// Resolve a git dependency to a `(checkout_directory, commit_hash)`.
62///
63/// A `locked_commit` from `leo.lock` is reused when its checkout exists and the reference is
64/// immutable (tag/rev) or `offline`; a mutable reference re-resolves against the remote when online.
65/// `offline` forbids network access, so it then succeeds only from an existing checkout.
66pub fn resolve(
67    home: &Path,
68    name: &str,
69    url: &str,
70    reference: &GitReference,
71    locked_commit: Option<&str>,
72    offline: bool,
73) -> Result<(PathBuf, String)> {
74    // Fast path: reuse a cached locked commit, but only for an immutable reference (or when
75    // offline) — a mutable branch tip must be re-resolved online.
76    if let Some(commit) = locked_commit
77        && (offline || !reference.is_mutable())
78    {
79        let dir = checkout_dir(home, url, commit);
80        if dir.is_dir() {
81            return Ok((dir, commit.to_string()));
82        }
83    }
84
85    if offline {
86        return Err(crate::errors::git_offline_unavailable(name, url).into());
87    }
88
89    // Clean up the transient clone whether resolution succeeds or fails (an interrupted fetch
90    // returns `Err`, so this still runs).
91    let tmp = unique_dir(&home.join(TMP_SUBDIR), "clone");
92    let result = clone_resolve_checkout(home, name, url, reference, locked_commit, &tmp);
93    let _ = std::fs::remove_dir_all(&tmp);
94    result
95}
96
97fn clone_resolve_checkout(
98    home: &Path,
99    name: &str,
100    url: &str,
101    reference: &GitReference,
102    locked_commit: Option<&str>,
103    tmp: &Path,
104) -> Result<(PathBuf, String)> {
105    let _ = std::fs::remove_dir_all(tmp);
106    if let Some(parent) = tmp.parent() {
107        std::fs::create_dir_all(parent).map_err(|e| crate::errors::git_error(name, url, e))?;
108    }
109
110    // A no-ref clone fetches all branches and tags, so any reference resolves against it.
111    let parsed = gix::url::parse(url.into()).map_err(|e| crate::errors::git_error(name, url, e))?;
112    let mut prepare = gix::prepare_clone(parsed, tmp)
113        .map_err(|e| crate::errors::git_error(name, url, e))?
114        .configure_connection(|connection| {
115            // Public repos only: never supply credentials, so a private repo fails rather than
116            // resolving via the developer's local git credentials (not reproducible elsewhere).
117            #[allow(clippy::result_large_err)] // gix's error type is large and not boxable here.
118            connection.set_credentials(|_action| Ok(None));
119            Ok(())
120        });
121    let (checkout, _) = prepare
122        .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
123        .map_err(|e| crate::errors::git_error(name, url, e))?;
124    // Keep the fetched objects but skip the default worktree; we check out an exact tree below.
125    let repo = checkout.persist();
126
127    let revspec = match (locked_commit, reference) {
128        // Immutable ref + locked commit: use the pin so a moved tag can't change the build.
129        (Some(commit), reference) if !reference.is_mutable() => commit.to_string(),
130        (_, GitReference::DefaultBranch) => "HEAD".to_string(),
131        (_, GitReference::Branch(branch)) => format!("origin/{branch}"),
132        (_, GitReference::Tag(tag)) => format!("refs/tags/{tag}"),
133        (_, GitReference::Rev(rev)) => rev.clone(),
134    };
135    let id = repo
136        .rev_parse_single(revspec.as_str())
137        .map_err(|e| crate::errors::git_reference_error(name, url, &revspec, e))?;
138    let commit = id.detach().to_string();
139
140    let dir = checkout_dir(home, url, &commit);
141    if !dir.is_dir() {
142        // Atomic rename-in so a concurrent/interrupted resolution never sees a partial checkout.
143        let staging = staging_dir(&dir);
144        let renamed =
145            checkout_tree(&repo, id, &staging).and_then(|()| std::fs::rename(&staging, &dir).map_err(Into::into));
146        let _ = std::fs::remove_dir_all(&staging);
147        // A rename failure is fine when another resolution won the race and the dir now exists.
148        if let Err(e) = renamed
149            && !dir.is_dir()
150        {
151            return Err(crate::errors::git_error(name, url, e).into());
152        }
153    }
154
155    Ok((dir, commit))
156}
157
158/// Materialise the tree of `id` into `dir` (a staging directory), which is (re)created empty.
159fn checkout_tree(
160    repo: &gix::Repository,
161    id: gix::Id<'_>,
162    dir: &Path,
163) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
164    let tree = id.object()?.peel_to_tree()?;
165    let mut index = repo.index_from_tree(&tree.id())?;
166    let mut opts = repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?;
167    opts.destination_is_initially_empty = true;
168
169    // Start clean so no files from a partial previous attempt linger.
170    let _ = std::fs::remove_dir_all(dir);
171    std::fs::create_dir_all(dir)?;
172
173    gix::worktree::state::checkout(
174        &mut index,
175        dir,
176        repo.objects.clone().into_arc()?,
177        &gix::progress::Discard,
178        &gix::progress::Discard,
179        &gix::interrupt::IS_INTERRUPTED,
180        opts,
181    )?;
182    Ok(())
183}
184
185/// A unique directory under `parent` for transient work (`<prefix>-<pid>-<seq>`).
186fn unique_dir(parent: &Path, prefix: &str) -> PathBuf {
187    static COUNTER: AtomicU64 = AtomicU64::new(0);
188    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
189    parent.join(format!("{prefix}-{}-{seq}", std::process::id()))
190}
191
192/// A unique staging directory next to `final_dir` (same filesystem, so the rename is atomic).
193fn staging_dir(final_dir: &Path) -> PathBuf {
194    // The leading `.` keeps a lingering staging dir out of the by-name package search.
195    unique_dir(final_dir.parent().unwrap_or(final_dir), ".staging")
196}