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 {
12 config: GitFetcherConfig,
13 cache_dir: PathBuf,
14}
15
16impl GitFetcher {
17 pub fn new(config: &GitFetcherConfig) -> Self {
18 let cache_dir = dirs::cache_dir()
19 .unwrap_or_else(|| PathBuf::from("/tmp"))
20 .join("thru-abi-git-cache");
21
22 Self {
23 config: config.clone(),
24 cache_dir,
25 }
26 }
27
28 fn cache_key(&self, url: &str) -> String {
29 let mut hasher = Sha256::new();
30 hasher.update(url.as_bytes());
31 let result = hasher.finalize();
32 hex::encode(&result[..16])
33 }
34
35 fn repo_cache_path(&self, url: &str) -> PathBuf {
36 self.cache_dir.join(self.cache_key(url))
37 }
38
39 fn create_fetch_options(&self) -> FetchOptions<'_> {
40 let mut callbacks = RemoteCallbacks::new();
41
42 let ssh_key_path = self.config.ssh_key_path.clone();
43 let use_credential_helper = self.config.use_credential_helper;
44
45 callbacks.credentials(move |url, username_from_url, allowed_types| {
46 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
47 if let Some(ref key_path) = ssh_key_path {
48 return Cred::ssh_key(username_from_url.unwrap_or("git"), None, key_path, None);
49 } else if let Ok(cred) =
50 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
51 {
52 return Ok(cred);
53 }
54 }
55
56 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
57 && use_credential_helper
58 {
59 return Cred::credential_helper(
60 &git2::Config::open_default().unwrap_or_else(|_| git2::Config::new().unwrap()),
61 url,
62 username_from_url,
63 );
64 }
65
66 if allowed_types.contains(git2::CredentialType::DEFAULT) {
67 return Cred::default();
68 }
69
70 Err(git2::Error::from_str("no authentication methods available"))
71 });
72
73 let mut fetch_options = FetchOptions::new();
74 fetch_options.remote_callbacks(callbacks);
75 fetch_options.download_tags(git2::AutotagOption::All);
76
77 if let Some(ref proxy_url) = self.config.proxy {
78 let mut proxy_opts = git2::ProxyOptions::new();
79 proxy_opts.url(proxy_url);
80 fetch_options.proxy_options(proxy_opts);
81 }
82
83 fetch_options
84 }
85
86 fn clone_or_fetch(&self, url: &str) -> Result<Repository, FetchError> {
87 let repo_path = self.repo_cache_path(url);
88
89 match Repository::open(&repo_path) {
90 Ok(repo) => {
91 let mut remote = repo
92 .find_remote("origin")
93 .map_err(|e| FetchError::Git(format!("Failed to find remote: {}", e)))?;
94
95 let mut fetch_options = self.create_fetch_options();
96 remote
97 .fetch(
98 &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
99 Some(&mut fetch_options),
100 None,
101 )
102 .map_err(|e| FetchError::Git(format!("Failed to fetch: {}", e)))?;
103 drop(remote);
104
105 Ok(repo)
106 }
107 Err(_) => {
108 if repo_path.exists() {
111 std::fs::remove_dir_all(&repo_path).map_err(FetchError::Io)?;
112 }
113 std::fs::create_dir_all(&self.cache_dir).map_err(FetchError::Io)?;
114
115 let mut builder = git2::build::RepoBuilder::new();
116 builder.fetch_options(self.create_fetch_options());
117
118 builder
119 .clone(url, &repo_path)
120 .map_err(|e| FetchError::Git(format!("Failed to clone {}: {}", url, e)))
121 }
122 }
123 }
124
125 fn read_file_at_ref(
126 &self,
127 repo: &Repository,
128 git_ref: &str,
129 path: &str,
130 ) -> Result<String, FetchError> {
131 let obj = repo
132 .revparse_single(git_ref)
133 .map_err(|e| FetchError::Git(format!("Failed to resolve ref '{}': {}", git_ref, e)))?;
134
135 let commit = obj
136 .peel_to_commit()
137 .map_err(|e| FetchError::Git(format!("Failed to get commit: {}", e)))?;
138
139 let tree = commit
140 .tree()
141 .map_err(|e| FetchError::Git(format!("Failed to get tree: {}", e)))?;
142
143 let entry = tree.get_path(std::path::Path::new(path)).map_err(|_| {
144 FetchError::NotFound(format!("File '{}' not found at ref '{}'", path, git_ref))
145 })?;
146
147 let blob = repo
148 .find_blob(entry.id())
149 .map_err(|e| FetchError::Git(format!("Failed to get blob: {}", e)))?;
150
151 let content = std::str::from_utf8(blob.content())
152 .map_err(|e| FetchError::Parse(format!("File is not valid UTF-8: {}", e)))?;
153
154 Ok(content.to_string())
155 }
156}
157
158impl ImportFetcher for GitFetcher {
159 fn handles(&self, source: &ImportSource) -> bool {
160 matches!(source, ImportSource::Git { .. })
161 }
162
163 fn fetch(&self, source: &ImportSource, _ctx: &FetchContext) -> Result<FetchResult, FetchError> {
164 let ImportSource::Git { url, git_ref, path } = source else {
165 return Err(FetchError::UnsupportedSource(
166 "GitFetcher only handles Git imports".to_string(),
167 ));
168 };
169
170 let repo = self.clone_or_fetch(url)?;
171 let content = self.read_file_at_ref(&repo, git_ref, path)?;
172 let canonical_location = format!("git:{}@{}:{}", url, git_ref, path);
173
174 Ok(FetchResult {
175 content,
176 canonical_location,
177 is_remote: true,
178 resolved_path: None,
179 })
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_git_fetcher_handles() {
189 let config = GitFetcherConfig::default();
190 let fetcher = GitFetcher::new(&config);
191
192 let git_import = ImportSource::Git {
193 url: "https://github.com/test/repo".to_string(),
194 git_ref: "main".to_string(),
195 path: "abi.yaml".to_string(),
196 };
197 let path_import = ImportSource::Path {
198 path: "local.abi.yaml".to_string(),
199 };
200
201 assert!(fetcher.handles(&git_import));
202 assert!(!fetcher.handles(&path_import));
203 }
204
205 #[test]
206 fn test_cache_key_generation() {
207 let config = GitFetcherConfig::default();
208 let fetcher = GitFetcher::new(&config);
209
210 let key1 = fetcher.cache_key("https://github.com/test/repo1");
211 let key2 = fetcher.cache_key("https://github.com/test/repo2");
212 let key1_again = fetcher.cache_key("https://github.com/test/repo1");
213
214 assert_ne!(key1, key2);
215 assert_eq!(key1, key1_again);
216 assert_eq!(key1.len(), 32); }
218}