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}