abi_loader/fetcher/
git.rs1use crate::fetcher::{FetchContext, FetchError, FetchResult, GitFetcherConfig, ImportFetcher};
6use crate::file::ImportSource;
7use git2::{Cred, FetchOptions, RemoteCallbacks, Repository};
8use sha2::{Digest, Sha256};
9use std::path::PathBuf;
10
11pub struct GitFetcher {
13 config: GitFetcherConfig,
14 cache_dir: PathBuf,
15}
16
17impl GitFetcher {
18 pub fn new(config: &GitFetcherConfig) -> Self {
20 let cache_dir = dirs::cache_dir()
21 .unwrap_or_else(|| PathBuf::from("/tmp"))
22 .join("thru-abi-git-cache");
23
24 Self {
25 config: config.clone(),
26 cache_dir,
27 }
28 }
29
30 fn cache_key(&self, url: &str) -> String {
32 let mut hasher = Sha256::new();
33 hasher.update(url.as_bytes());
34 let result = hasher.finalize();
35 hex::encode(&result[..16])
36 }
37
38 fn repo_cache_path(&self, url: &str) -> PathBuf {
40 self.cache_dir.join(self.cache_key(url))
41 }
42
43 fn create_fetch_options(&self) -> FetchOptions<'_> {
45 let mut callbacks = RemoteCallbacks::new();
46
47 let ssh_key_path = self.config.ssh_key_path.clone();
49 let use_credential_helper = self.config.use_credential_helper;
50
51 callbacks.credentials(move |url, username_from_url, allowed_types| {
52 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
54 if let Some(ref key_path) = ssh_key_path {
55 return Cred::ssh_key(
57 username_from_url.unwrap_or("git"),
58 None,
59 key_path,
60 None,
61 );
62 } else {
63 if let Ok(cred) = Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
65 return Ok(cred);
66 }
67 }
68 }
69
70 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
72 && use_credential_helper
73 {
74 return Cred::credential_helper(
75 &git2::Config::open_default().unwrap_or_else(|_| git2::Config::new().unwrap()),
76 url,
77 username_from_url,
78 );
79 }
80
81 if allowed_types.contains(git2::CredentialType::DEFAULT) {
83 return Cred::default();
84 }
85
86 Err(git2::Error::from_str("no authentication methods available"))
87 });
88
89 let mut fetch_options = FetchOptions::new();
90 fetch_options.remote_callbacks(callbacks);
91 fetch_options.download_tags(git2::AutotagOption::All);
92
93 if let Some(ref proxy_url) = self.config.proxy {
95 let mut proxy_opts = git2::ProxyOptions::new();
96 proxy_opts.url(proxy_url);
97 fetch_options.proxy_options(proxy_opts);
98 }
99
100 fetch_options
101 }
102
103 fn clone_or_fetch(&self, url: &str) -> Result<Repository, FetchError> {
105 let repo_path = self.repo_cache_path(url);
106
107 if repo_path.exists() {
108 match Repository::open(&repo_path) {
110 Ok(repo) => {
111 {
113 let mut remote = repo
114 .find_remote("origin")
115 .map_err(|e| FetchError::Git(format!("Failed to find remote: {}", e)))?;
116
117 let mut fetch_options = self.create_fetch_options();
118 remote
119 .fetch(
120 &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
121 Some(&mut fetch_options),
122 None,
123 )
124 .map_err(|e| FetchError::Git(format!("Failed to fetch: {}", e)))?;
125 } return Ok(repo);
128 }
129 Err(_) => {
130 let _ = std::fs::remove_dir_all(&repo_path);
132 }
133 }
134 }
135
136 std::fs::create_dir_all(&self.cache_dir)
138 .map_err(|e| FetchError::Io(e))?;
139
140 let mut builder = git2::build::RepoBuilder::new();
142 builder.fetch_options(self.create_fetch_options());
143
144 builder
145 .clone(url, &repo_path)
146 .map_err(|e| FetchError::Git(format!("Failed to clone {}: {}", url, e)))
147 }
148
149 #[allow(dead_code)]
151 fn checkout_ref(&self, repo: &Repository, git_ref: &str) -> Result<(), FetchError> {
152 let obj = repo
154 .revparse_single(git_ref)
155 .map_err(|e| FetchError::Git(format!("Failed to resolve ref '{}': {}", git_ref, e)))?;
156
157 repo.checkout_tree(&obj, None)
159 .map_err(|e| FetchError::Git(format!("Failed to checkout '{}': {}", git_ref, e)))?;
160
161 repo.set_head_detached(obj.id())
163 .map_err(|e| FetchError::Git(format!("Failed to set HEAD: {}", e)))?;
164
165 Ok(())
166 }
167
168 fn read_file_at_ref(
170 &self,
171 repo: &Repository,
172 git_ref: &str,
173 path: &str,
174 ) -> Result<String, FetchError> {
175 let obj = repo
177 .revparse_single(git_ref)
178 .map_err(|e| FetchError::Git(format!("Failed to resolve ref '{}': {}", git_ref, e)))?;
179
180 let commit = obj
181 .peel_to_commit()
182 .map_err(|e| FetchError::Git(format!("Failed to get commit: {}", e)))?;
183
184 let tree = commit
185 .tree()
186 .map_err(|e| FetchError::Git(format!("Failed to get tree: {}", e)))?;
187
188 let entry = tree
190 .get_path(std::path::Path::new(path))
191 .map_err(|_| FetchError::NotFound(format!("File '{}' not found at ref '{}'", path, git_ref)))?;
192
193 let blob = repo
194 .find_blob(entry.id())
195 .map_err(|e| FetchError::Git(format!("Failed to get blob: {}", e)))?;
196
197 let content = std::str::from_utf8(blob.content())
199 .map_err(|e| FetchError::Parse(format!("File is not valid UTF-8: {}", e)))?;
200
201 Ok(content.to_string())
202 }
203}
204
205impl ImportFetcher for GitFetcher {
206 fn handles(&self, source: &ImportSource) -> bool {
207 matches!(source, ImportSource::Git { .. })
208 }
209
210 fn fetch(&self, source: &ImportSource, _ctx: &FetchContext) -> Result<FetchResult, FetchError> {
211 let ImportSource::Git { url, git_ref, path } = source else {
212 return Err(FetchError::UnsupportedSource(
213 "GitFetcher only handles Git imports".to_string(),
214 ));
215 };
216
217 let repo = self.clone_or_fetch(url)?;
219
220 let content = self.read_file_at_ref(&repo, git_ref, path)?;
222
223 let canonical_location = format!("git:{}@{}:{}", url, git_ref, path);
225
226 Ok(FetchResult {
227 content,
228 canonical_location,
229 is_remote: true,
230 resolved_path: None,
231 })
232 }
233}
234
235mod hex {
237 pub fn encode(bytes: &[u8]) -> String {
238 bytes.iter().map(|b| format!("{:02x}", b)).collect()
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_git_fetcher_handles() {
248 let config = GitFetcherConfig::default();
249 let fetcher = GitFetcher::new(&config);
250
251 let git_import = ImportSource::Git {
252 url: "https://github.com/test/repo".to_string(),
253 git_ref: "main".to_string(),
254 path: "abi.yaml".to_string(),
255 };
256 let path_import = ImportSource::Path {
257 path: "local.abi.yaml".to_string(),
258 };
259
260 assert!(fetcher.handles(&git_import));
261 assert!(!fetcher.handles(&path_import));
262 }
263
264 #[test]
265 fn test_cache_key_generation() {
266 let config = GitFetcherConfig::default();
267 let fetcher = GitFetcher::new(&config);
268
269 let key1 = fetcher.cache_key("https://github.com/test/repo1");
270 let key2 = fetcher.cache_key("https://github.com/test/repo2");
271 let key1_again = fetcher.cache_key("https://github.com/test/repo1");
272
273 assert_ne!(key1, key2);
274 assert_eq!(key1, key1_again);
275 assert_eq!(key1.len(), 32); }
277}