lean_ctx/core/git/
clone.rs1use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12use super::repo_url::RepoRef;
13use super::run_git;
14
15pub const DEFAULT_CLONE_TIMEOUT_SECS: u64 = 90;
17const CACHE_TTL: Duration = Duration::from_hours(1);
19const STAMP: &str = ".lean-ctx-fetched";
21
22pub fn ensure_repo(repo: &RepoRef, timeout: Duration) -> Result<PathBuf, String> {
28 guard_clone_url(&repo.clone_url)?;
29
30 let dir = repo_cache_dir(repo)?;
31 if is_fresh(&dir) {
32 return Ok(dir);
33 }
34
35 if dir.join(".git").is_dir() {
36 if let Err(e) = refresh(repo, &dir, timeout) {
37 let _ = std::fs::remove_dir_all(&dir);
39 initial_fetch(repo, &dir, timeout)
40 .map_err(|e2| format!("refresh failed ({e}); reclone failed ({e2})"))?;
41 }
42 } else {
43 let _ = std::fs::remove_dir_all(&dir);
44 initial_fetch(repo, &dir, timeout)?;
45 }
46 Ok(dir)
47}
48
49fn guard_clone_url(url: &str) -> Result<(), String> {
51 let safe = crate::core::web::url_guard::validate(url).map_err(|e| e.to_string())?;
52 safe.ensure_resolves_safely().map_err(|e| e.to_string())?;
53 Ok(())
54}
55
56pub fn repo_cache_dir(repo: &RepoRef) -> Result<PathBuf, String> {
59 let mut dir = cache_root()?;
60 for seg in repo.cache_slug().split('/') {
61 dir.push(sanitize_segment(seg));
62 }
63 let ref_seg = repo
64 .git_ref
65 .as_deref()
66 .map_or_else(|| "_HEAD".to_string(), sanitize_segment);
67 dir.push(ref_seg);
68 Ok(dir)
69}
70
71fn cache_root() -> Result<PathBuf, String> {
72 Ok(crate::core::data_dir::lean_ctx_data_dir()?
73 .join("cache")
74 .join("repos"))
75}
76
77fn sanitize_segment(seg: &str) -> String {
80 let cleaned: String = seg
81 .chars()
82 .map(|c| {
83 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
84 c
85 } else {
86 '_'
87 }
88 })
89 .collect();
90 match cleaned.as_str() {
91 "" | "." | ".." => "_".to_string(),
92 _ => cleaned,
93 }
94}
95
96fn is_fresh(dir: &Path) -> bool {
97 if !dir.join(".git").is_dir() {
98 return false;
99 }
100 let Ok(meta) = std::fs::metadata(dir.join(STAMP)) else {
101 return false;
102 };
103 let Ok(modified) = meta.modified() else {
104 return false;
105 };
106 SystemTime::now()
107 .duration_since(modified)
108 .is_ok_and(|age| age < CACHE_TTL)
109}
110
111fn initial_fetch(repo: &RepoRef, dir: &Path, timeout: Duration) -> Result<(), String> {
114 std::fs::create_dir_all(dir).map_err(|e| format!("cannot create cache dir: {e}"))?;
115 run_git(&["init", "-q"], dir, Duration::from_secs(15), &[])?.ok_stdout()?;
116 run_git(
117 &["remote", "add", "origin", &repo.clone_url],
118 dir,
119 Duration::from_secs(15),
120 &[],
121 )?
122 .ok_stdout()?;
123 fetch_and_checkout(repo, dir, timeout)
124}
125
126fn refresh(repo: &RepoRef, dir: &Path, timeout: Duration) -> Result<(), String> {
127 fetch_and_checkout(repo, dir, timeout)
128}
129
130fn fetch_and_checkout(repo: &RepoRef, dir: &Path, timeout: Duration) -> Result<(), String> {
131 let refspec = repo.git_ref.as_deref().unwrap_or("HEAD");
132 run_git(
133 &["fetch", "--depth", "1", "origin", refspec],
134 dir,
135 timeout,
136 &[],
137 )?
138 .ok_stdout()
139 .map_err(|e| format!("fetch '{refspec}' from {}: {e}", repo.clone_url))?;
140 run_git(
141 &["checkout", "-q", "-f", "FETCH_HEAD"],
142 dir,
143 Duration::from_secs(30),
144 &[],
145 )?
146 .ok_stdout()?;
147 let _ = std::fs::write(dir.join(STAMP), b"");
148 Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn rr(url: &str) -> RepoRef {
156 crate::core::git::repo_url::parse(url).unwrap()
157 }
158
159 #[test]
160 fn cache_dir_nests_host_owner_repo_ref() {
161 let _lock = crate::core::data_dir::test_env_lock();
162 let tmp = std::env::temp_dir().join("lc_clone_cache_test");
163 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &tmp);
164 let dir = repo_cache_dir(&rr("https://github.com/o/r/blob/main/x.rs")).unwrap();
165 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
166
167 let s = dir.to_string_lossy().replace('\\', "/");
169 assert!(s.contains("cache/repos/github.com/o/r/main"), "got {s}");
170 }
171
172 #[test]
173 fn cache_dir_uses_head_marker_without_ref() {
174 let _lock = crate::core::data_dir::test_env_lock();
175 let tmp = std::env::temp_dir().join("lc_clone_cache_test2");
176 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &tmp);
177 let dir = repo_cache_dir(&rr("https://github.com/o/r")).unwrap();
178 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
179 assert!(dir.ends_with("_HEAD"), "got {}", dir.display());
181 }
182
183 #[test]
184 fn sanitize_blocks_traversal_and_weird_chars() {
185 assert_eq!(sanitize_segment(".."), "_");
186 assert_eq!(sanitize_segment("."), "_");
187 assert_eq!(sanitize_segment(""), "_");
188 assert_eq!(sanitize_segment("a/b"), "a_b");
189 assert_eq!(sanitize_segment("feat..x"), "feat..x"); assert_eq!(sanitize_segment("we ird*name"), "we_ird_name");
191 assert_eq!(sanitize_segment("ok-1.2_3"), "ok-1.2_3");
192 }
193
194 #[test]
195 fn ensure_repo_rejects_non_https_and_loopback() {
196 assert!(
198 ensure_repo(
199 &RepoRef {
200 host: "localhost".into(),
201 owner: "o".into(),
202 repo: "r".into(),
203 clone_url: "http://localhost/o/r.git".into(),
204 git_ref: None,
205 subpath: None,
206 },
207 Duration::from_secs(5)
208 )
209 .is_err()
210 );
211 }
212
213 #[test]
214 fn fresh_is_false_for_missing_dir() {
215 assert!(!is_fresh(Path::new("/nonexistent/lean-ctx/repo/cache")));
216 }
217}