Skip to main content

abi_loader/fetcher/
git.rs

1//! Git Repository Import Fetcher
2//!
3//! Fetches ABI files from git repositories with support for branch, tag, and commit pinning.
4
5use 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
11/* Git repository fetcher */
12pub struct GitFetcher {
13    config: GitFetcherConfig,
14    cache_dir: PathBuf,
15}
16
17impl GitFetcher {
18    /* Create a new git fetcher with the given configuration */
19    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    /* Generate a cache key for a repository URL */
31    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    /* Get the cached repository path */
39    fn repo_cache_path(&self, url: &str) -> PathBuf {
40        self.cache_dir.join(self.cache_key(url))
41    }
42
43    /* Create fetch options with authentication callbacks */
44    fn create_fetch_options(&self) -> FetchOptions<'_> {
45        let mut callbacks = RemoteCallbacks::new();
46
47        /* Set up credentials callback */
48        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            /* Try SSH key authentication first */
53            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
54                if let Some(ref key_path) = ssh_key_path {
55                    /* Use explicit SSH key */
56                    return Cred::ssh_key(
57                        username_from_url.unwrap_or("git"),
58                        None,
59                        key_path,
60                        None,
61                    );
62                } else {
63                    /* Try SSH agent */
64                    if let Ok(cred) = Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
65                        return Ok(cred);
66                    }
67                }
68            }
69
70            /* Try credential helper for HTTPS */
71            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            /* Try default credentials */
82            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        /* Set proxy if configured */
94        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    /* Clone or update a repository */
104    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            /* Try to open existing repo and fetch updates */
109            match Repository::open(&repo_path) {
110                Ok(repo) => {
111                    /* Fetch latest from origin */
112                    {
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                    } /* Drop remote here before returning repo */
126
127                    return Ok(repo);
128                }
129                Err(_) => {
130                    /* Remove corrupted cache and re-clone */
131                    let _ = std::fs::remove_dir_all(&repo_path);
132                }
133            }
134        }
135
136        /* Create cache directory if needed */
137        std::fs::create_dir_all(&self.cache_dir)
138            .map_err(|e| FetchError::Io(e))?;
139
140        /* Clone the repository */
141        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    /* Checkout a specific ref (branch, tag, or commit) */
150    #[allow(dead_code)]
151    fn checkout_ref(&self, repo: &Repository, git_ref: &str) -> Result<(), FetchError> {
152        /* Try to resolve the ref */
153        let obj = repo
154            .revparse_single(git_ref)
155            .map_err(|e| FetchError::Git(format!("Failed to resolve ref '{}': {}", git_ref, e)))?;
156
157        /* Checkout the commit */
158        repo.checkout_tree(&obj, None)
159            .map_err(|e| FetchError::Git(format!("Failed to checkout '{}': {}", git_ref, e)))?;
160
161        /* Set HEAD to detached state at the commit */
162        repo.set_head_detached(obj.id())
163            .map_err(|e| FetchError::Git(format!("Failed to set HEAD: {}", e)))?;
164
165        Ok(())
166    }
167
168    /* Read a file from the repository at a specific ref */
169    fn read_file_at_ref(
170        &self,
171        repo: &Repository,
172        git_ref: &str,
173        path: &str,
174    ) -> Result<String, FetchError> {
175        /* Resolve the ref to a commit */
176        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        /* Find the file in the tree */
189        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        /* Read content as UTF-8 */
198        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        /* Clone or update the repository */
218        let repo = self.clone_or_fetch(url)?;
219
220        /* Read the file at the specified ref */
221        let content = self.read_file_at_ref(&repo, git_ref, path)?;
222
223        /* Create canonical location identifier */
224        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
235/* Hex encoding helper (simple implementation to avoid extra dependency) */
236mod 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); /* SHA256 truncated to 16 bytes = 32 hex chars */
276    }
277}