Skip to main content

pray_core/
resolve_git.rs

1use crate::client_trust::{effective_trust_home, gate_git_source};
2use crate::hashing::sha256_prefixed;
3use crate::paths::remove_path_if_exists;
4use crate::resolve_git_command::{command_error, run_git_command, run_git_success};
5use crate::{PrayError, PrayResult};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9pub(crate) fn ensure_git_repository(
10    project_root: &Path,
11    clone_url: &str,
12    refresh: bool,
13    pinned_revision: Option<&str>,
14    sparse_subdir: Option<&str>,
15) -> PrayResult<(PathBuf, String)> {
16    let git_cache_directory = git_source_cache_directory(project_root, clone_url);
17
18    if git_cache_directory.join(".git").is_dir() {
19        if refresh {
20            refresh_global_git_cache(clone_url)?;
21        }
22        if let Some(revision) = pinned_revision {
23            checkout_git_revision(&git_cache_directory, clone_url, revision, refresh)?;
24        } else if refresh {
25            refresh_git_worktree(&git_cache_directory, clone_url)?;
26        }
27        if let Some(subdir) = sparse_subdir {
28            apply_sparse_checkout(&git_cache_directory, subdir)?;
29        }
30        let revision = git_head_revision(&git_cache_directory)?;
31        return finalize_git_repository(clone_url, &git_cache_directory, revision);
32    }
33
34    if git_cache_directory.exists() {
35        remove_path_if_exists(&git_cache_directory)?;
36    }
37    if let Some(parent) = git_cache_directory.parent() {
38        fs::create_dir_all(parent)?;
39    }
40    let destination = git_cache_directory.to_str().ok_or_else(|| {
41        PrayError::Resolution(format!("invalid git cache path: {:?}", git_cache_directory))
42    })?;
43    if seed_git_cache_from_global(clone_url, destination, project_root)? {
44        ensure_git_remote_origin(&git_cache_directory, clone_url)?;
45    } else {
46        run_git_success(
47            project_root,
48            &["clone", "--depth", "1", clone_url, destination],
49        )?;
50        let _ = mirror_git_cache_to_global(clone_url, &git_cache_directory);
51    }
52    if let Some(revision) = pinned_revision {
53        checkout_git_revision(&git_cache_directory, clone_url, revision, true)?;
54    }
55    if let Some(subdir) = sparse_subdir {
56        apply_sparse_checkout(&git_cache_directory, subdir)?;
57    }
58    let revision = git_head_revision(&git_cache_directory)?;
59    finalize_git_repository(clone_url, &git_cache_directory, revision)
60}
61
62pub(crate) fn global_cache_root() -> Option<PathBuf> {
63    if let Ok(path) = std::env::var("PRAY_CACHE") {
64        return Some(PathBuf::from(path));
65    }
66    if let Ok(home) = std::env::var("PRAY_HOME") {
67        return Some(PathBuf::from(home).join("cache"));
68    }
69    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache").join("pray"))
70}
71
72pub(crate) fn global_git_cache_directory(clone_url: &str) -> Option<PathBuf> {
73    global_cache_root().map(|root| root.join("git").join(cache_key(clone_url)))
74}
75
76pub(crate) fn global_git_cache_ready(global_cache: &Path) -> bool {
77    global_cache.join(".git").is_dir() || global_cache.join("HEAD").is_file()
78}
79
80pub(crate) fn seed_git_cache_from_global(
81    clone_url: &str,
82    destination: &str,
83    working_directory: &Path,
84) -> PrayResult<bool> {
85    let Some(global_cache) = global_git_cache_directory(clone_url) else {
86        return Ok(false);
87    };
88    if !global_git_cache_ready(&global_cache) {
89        return Ok(false);
90    }
91    let global_path = global_cache.to_str().ok_or_else(|| {
92        PrayError::Resolution(format!("invalid global git cache path: {:?}", global_cache))
93    })?;
94    run_git_success(
95        working_directory,
96        &["clone", "--depth", "1", "--quiet", global_path, destination],
97    )?;
98    Ok(true)
99}
100
101pub(crate) fn mirror_git_cache_to_global(clone_url: &str, project_cache: &Path) -> PrayResult<()> {
102    let Some(global_cache) = global_git_cache_directory(clone_url) else {
103        return Ok(());
104    };
105    if global_git_cache_ready(&global_cache) {
106        return Ok(());
107    }
108    let cache_parent = project_cache.parent().ok_or_else(|| {
109        PrayError::Resolution(format!(
110            "invalid project git cache path: {:?}",
111            project_cache
112        ))
113    })?;
114    let cache_name = project_cache
115        .file_name()
116        .and_then(|name| name.to_str())
117        .ok_or_else(|| {
118            PrayError::Resolution(format!(
119                "invalid project git cache path: {:?}",
120                project_cache
121            ))
122        })?;
123    if let Some(parent) = global_cache.parent() {
124        fs::create_dir_all(parent)?;
125    }
126    let destination = global_cache.to_str().ok_or_else(|| {
127        PrayError::Resolution(format!("invalid global git cache path: {:?}", global_cache))
128    })?;
129    if global_cache.exists() {
130        remove_path_if_exists(&global_cache)?;
131    }
132    run_git_success(
133        cache_parent,
134        &["clone", "--bare", "--quiet", cache_name, destination],
135    )?;
136    Ok(())
137}
138
139pub(crate) fn apply_sparse_checkout(repository: &Path, subdir: &str) -> PrayResult<()> {
140    run_git_success(repository, &["sparse-checkout", "init", "--cone"])?;
141    run_git_success(repository, &["sparse-checkout", "set", subdir])?;
142    Ok(())
143}
144
145pub(crate) fn resolve_distribution_root(
146    repo_root: &Path,
147    subdir: Option<&str>,
148) -> PrayResult<PathBuf> {
149    if let Some(subdir) = subdir {
150        let path = repo_root.join(subdir);
151        if is_local_distribution_root(&path) {
152            return Ok(path);
153        }
154        return Err(PrayError::Resolution(format!(
155            "no pray distribution root at subdir {:?} in git source {:?}",
156            path, repo_root
157        )));
158    }
159    require_distribution_root(repo_root)
160}
161
162pub(crate) fn finalize_git_repository(
163    clone_url: &str,
164    git_cache_directory: &Path,
165    revision: String,
166) -> PrayResult<(PathBuf, String)> {
167    gate_git_source(&effective_trust_home()?, clone_url, git_cache_directory)?;
168    if crate::client_trust::env_truthy("PRAY_TRUST_IMPORT") {
169        let global_scope = crate::client_trust::env_truthy("PRAY_TRUST_GLOBAL");
170        crate::client_trust::prompt_import_signing_keys_for_source(
171            &effective_trust_home()?,
172            clone_url,
173            git_cache_directory,
174            global_scope,
175        )?;
176    }
177    Ok((git_cache_directory.to_path_buf(), revision))
178}
179
180pub fn git_source_cache_directory(project_root: &Path, clone_url: &str) -> PathBuf {
181    project_root
182        .join(".pray/cache/git")
183        .join(cache_key(clone_url))
184}
185
186pub(crate) fn ensure_git_remote_origin(repository: &Path, clone_url: &str) -> PrayResult<()> {
187    if run_git_success(repository, &["remote", "get-url", "origin"]).is_ok() {
188        run_git_success(repository, &["remote", "set-url", "origin", clone_url])?;
189    } else {
190        run_git_success(repository, &["remote", "add", "origin", clone_url])?;
191    }
192    Ok(())
193}
194
195pub(crate) fn refresh_global_git_cache(clone_url: &str) -> PrayResult<()> {
196    let Some(global_cache) = global_git_cache_directory(clone_url) else {
197        return Ok(());
198    };
199    if !global_git_cache_ready(&global_cache) {
200        return Ok(());
201    }
202    ensure_git_remote_origin(&global_cache, clone_url)?;
203    run_git_success(&global_cache, &["fetch", "--depth", "1", "origin"])?;
204    Ok(())
205}
206
207pub(crate) fn refresh_git_worktree(repository: &Path, clone_url: &str) -> PrayResult<()> {
208    ensure_git_remote_origin(repository, clone_url)?;
209    run_git_success(repository, &["fetch", "--depth", "1", "origin"])?;
210    run_git_success(repository, &["reset", "--hard", "FETCH_HEAD"])?;
211    Ok(())
212}
213
214pub(crate) fn checkout_git_revision(
215    repository: &Path,
216    clone_url: &str,
217    revision: &str,
218    allow_fetch: bool,
219) -> PrayResult<()> {
220    if git_object_exists(repository, revision) {
221        run_git_success(repository, &["reset", "--hard", revision])?;
222        return Ok(());
223    }
224    if !allow_fetch {
225        return Err(PrayError::Resolution(format!(
226            "git source {:?} is locked to revision {revision}, but that commit is not available locally; rerun pray install without --locked to refresh the cache",
227            repository
228        )));
229    }
230    ensure_git_remote_origin(repository, clone_url)?;
231    run_git_success(repository, &["fetch", "--depth", "1", "origin", revision])?;
232    if git_object_exists(repository, revision) {
233        run_git_success(repository, &["reset", "--hard", revision])?;
234        return Ok(());
235    }
236    run_git_success(repository, &["fetch", "origin", revision])?;
237    run_git_success(repository, &["reset", "--hard", revision])?;
238    Ok(())
239}
240
241pub(crate) fn git_object_exists(repository: &Path, object: &str) -> bool {
242    run_git_success(repository, &["cat-file", "-e", object]).is_ok()
243}
244
245pub(crate) fn git_head_revision(repository: &Path) -> PrayResult<String> {
246    let output = run_git_command(repository, &["rev-parse", "HEAD"])?;
247    if !output.status.success() {
248        return Err(command_error("git rev-parse HEAD", output));
249    }
250    let revision = String::from_utf8_lossy(&output.stdout).trim().to_string();
251    if revision.is_empty() {
252        return Err(PrayError::Resolution(
253            "git repository has no HEAD revision".to_string(),
254        ));
255    }
256    Ok(revision)
257}
258
259pub(crate) fn require_distribution_root(repo_root: &Path) -> PrayResult<PathBuf> {
260    discover_distribution_root(repo_root).ok_or_else(|| {
261        PrayError::Resolution(format!(
262            "no pray distribution root in git source {:?}. \
263             Expected v1/packages at the repository root or under prayers/. \
264             Publish with `pray publish --root ./prayers` or point the source at a distribution repository.",
265            repo_root
266        ))
267    })
268}
269
270pub(crate) fn local_git_source_root(clone_url: &str) -> Option<PathBuf> {
271    let path = if let Some(path) = clone_url.strip_prefix("file://") {
272        PathBuf::from(path)
273    } else {
274        PathBuf::from(clone_url)
275    };
276
277    if !path.exists() {
278        return None;
279    }
280    discover_distribution_root(&path)
281}
282
283pub fn discover_distribution_root(path: &Path) -> Option<PathBuf> {
284    if is_local_distribution_root(path) {
285        return Some(path.to_path_buf());
286    }
287
288    let prayers_root = path.join("prayers");
289    if is_local_distribution_root(&prayers_root) {
290        return Some(prayers_root);
291    }
292
293    None
294}
295
296pub(crate) fn is_local_distribution_root(path: &Path) -> bool {
297    path.join("v1/packages").is_dir()
298}
299
300pub(crate) fn cache_key(text: &str) -> String {
301    sha256_prefixed(text.as_bytes())
302        .trim_start_matches("sha256:")
303        .chars()
304        .take(16)
305        .collect()
306}