git_meta/
info.rs

1use crate::{
2    BranchHeads, GitCommitMeta, GitCredentials, GitRepo, GitRepoCloneRequest, GitRepoInfo,
3};
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use color_eyre::eyre::{eyre, Context, ContextCompat, Result};
9use git2::{Branch, BranchType, Commit, Cred, Oid, Repository};
10use mktemp::Temp;
11use tracing::debug;
12
13impl GitRepoInfo {
14    pub fn to_repo(&self) -> GitRepo {
15        self.into()
16    }
17
18    pub fn to_clone(&self) -> GitRepoCloneRequest {
19        self.into()
20    }
21
22    /// Return the remote name from the given `git2::Repository`
23    /// For example, the typical remote name: `origin`
24    pub fn get_remote_name(&self, r: &git2::Repository) -> Result<String> {
25        let local_branch = r.head().and_then(|h| h.resolve())?;
26        let local_branch = local_branch.name();
27
28        if let Some(refname) = local_branch {
29            let upstream_remote = r.branch_upstream_remote(refname)?;
30
31            if let Some(name) = upstream_remote.as_str() {
32                Ok(name.to_string())
33            } else {
34                Err(eyre!("Upstream remote name not valid utf-8"))
35            }
36        } else {
37            Err(eyre!("Local branch name not valid utf-8"))
38        }
39    }
40
41    /// Return a `HashMap<String, GitCommitMeta>` for a branch containing
42    /// the branch names and the latest commit of the branch`.
43    /// Providing a `branch_filter` will only return branches based on
44    /// patterns matching the start of the branch name.
45    pub fn get_remote_branch_head_refs(
46        &self,
47        branch_filter: Option<Vec<String>>,
48    ) -> Result<BranchHeads> {
49        // Create a temp directory (In case we need to clone)
50        let temp_dir = if let Ok(temp_dir) = Temp::new_dir() {
51            temp_dir
52        } else {
53            return Err(eyre!("Unable to create temp directory"));
54        };
55
56        // Check on path. If it doesn't exist, then we gotta clone and open the repo
57        // so we can have a git2::Repository to work with
58        let repo = if let Some(p) = self.path.clone() {
59            GitRepo::to_repository_from_path(p)?
60        } else {
61            // Shallow clone
62
63            let clone: GitRepoCloneRequest = self.into();
64            clone
65                .git_clone_shallow(temp_dir.as_path())?
66                .to_repository()?
67        };
68
69        let cb = self.build_git2_remotecallback();
70
71        let remote_name = if let Ok(name) = self.get_remote_name(&repo) {
72            name
73        } else {
74            return Err(eyre!("Could not read remote name from git2::Repository"));
75        };
76
77        let mut remote = if let Ok(r) = repo.find_remote(&remote_name) {
78            r
79        } else if let Ok(anon_remote) = repo.remote_anonymous(&remote_name) {
80            anon_remote
81        } else {
82            return Err(eyre!(
83                "Could not create anonymous remote from: {:?}",
84                &remote_name
85            ));
86        };
87
88        // Connect to the remote and call the printing function for each of the
89        // remote references.
90        let connection =
91            if let Ok(conn) = remote.connect_auth(git2::Direction::Fetch, Some(cb?), None) {
92                conn
93            } else {
94                return Err(eyre!("Unable to connect to git repo"));
95            };
96
97        let git_branch_ref_prefix = "refs/heads/";
98        let mut ref_map: HashMap<String, GitCommitMeta> = HashMap::new();
99
100        for git_ref in connection
101            .list()?
102            .iter()
103            .filter(|head| head.name().starts_with(git_branch_ref_prefix))
104        {
105            let branch_name = git_ref
106                .name()
107                .to_string()
108                .rsplit(git_branch_ref_prefix)
109                .collect::<Vec<&str>>()[0]
110                .to_string();
111
112            if let Some(ref branches) = branch_filter {
113                if branches.contains(&branch_name.to_string()) {
114                    continue;
115                }
116            }
117
118            // Get the commit object
119            let commit = repo.find_commit(git_ref.oid())?;
120
121            let head_commit = GitCommitMeta::new(commit.id().as_bytes())
122                .with_timestamp(commit.time().seconds())
123                .with_message(commit.message().map(|m| m.to_string()));
124
125            ref_map.insert(branch_name, head_commit);
126        }
127
128        Ok(ref_map)
129    }
130
131    /// Returns a `bool` if a commit exists in the branch using the `git2` crate
132    pub fn is_commit_in_branch<'repo>(
133        r: &'repo Repository,
134        commit: &Commit,
135        branch: &Branch,
136    ) -> Result<bool> {
137        let branch_head = branch.get().peel_to_commit();
138
139        if branch_head.is_err() {
140            return Ok(false);
141        }
142
143        let branch_head = branch_head.wrap_err("Unable to extract branch HEAD commit")?;
144        if branch_head.id() == commit.id() {
145            return Ok(true);
146        }
147
148        // We get here if we're not working with HEAD commits, and we gotta dig deeper
149
150        let check_commit_in_branch = r.graph_descendant_of(branch_head.id(), commit.id());
151        //println!("is {:?} a decendent of {:?}: {:?}", &commit.id(), &branch_head.id(), is_commit_in_branch);
152
153        if check_commit_in_branch.is_err() {
154            return Ok(false);
155        }
156
157        check_commit_in_branch.wrap_err("Unable to determine if commit exists within branch")
158    }
159
160    /// Return the `git2::Branch` struct for a local repo (as opposed to a remote repo)
161    /// If `local_branch` is not provided, we'll select the current active branch, based on HEAD
162    pub fn get_git2_branch<'repo>(
163        r: &'repo Repository,
164        local_branch: &Option<String>,
165    ) -> Result<Option<Branch<'repo>>> {
166        match local_branch {
167            Some(branch) => {
168                //println!("User passed branch: {:?}", branch);
169                if let Ok(git2_branch) = r.find_branch(branch, BranchType::Local) {
170                    debug!("Returning given branch: {:?}", &git2_branch.name());
171                    Ok(Some(git2_branch))
172                } else {
173                    // If detached HEAD, we won't have a branch
174                    Ok(None)
175                }
176            }
177            None => {
178                // Getting the HEAD of the current
179                let head = r.head();
180
181                // Find the current local branch...
182                let local_branch = Branch::wrap(head?);
183
184                debug!("Returning HEAD branch: {:?}", local_branch.name()?);
185
186                let maybe_local_branch_name = if let Ok(Some(name)) = local_branch.name() {
187                    Some(name)
188                } else {
189                    // This occurs when you check out commit (i.e., detached HEAD).
190                    None
191                };
192
193                if let Some(local_branch_name) = maybe_local_branch_name {
194                    match r.find_branch(local_branch_name, BranchType::Local) {
195                        Ok(b) => Ok(Some(b)),
196                        Err(_e) => Ok(None),
197                    }
198                } else {
199                    Ok(None)
200                }
201            }
202        }
203    }
204
205    /// Return the remote url from the given Repository
206    ///
207    /// Returns `None` if current branch is local only
208    pub fn remote_url_from_repository(r: &Repository) -> Result<Option<String>> {
209        // Get the name of the remote from the Repository
210        let remote_name = GitRepoInfo::remote_name_from_repository(r)?;
211
212        if let Some(remote) = remote_name {
213            let remote_url: String = if let Some(url) = r.find_remote(&remote)?.url() {
214                url.chars().collect()
215            } else {
216                return Err(eyre!("Unable to extract repo url from remote"));
217            };
218
219            Ok(Some(remote_url))
220        } else {
221            Ok(None)
222        }
223    }
224
225    /// Return the remote name from the given Repository
226    fn remote_name_from_repository(r: &Repository) -> Result<Option<String>> {
227        let local_branch = r.head().and_then(|h| h.resolve())?;
228        let local_branch_name = if let Some(name) = local_branch.name() {
229            name
230        } else {
231            return Err(eyre!("Local branch name is not valid utf-8"));
232        };
233
234        let upstream_remote_name_buf =
235            if let Ok(remote) = r.branch_upstream_remote(local_branch_name) {
236                Some(remote)
237            } else {
238                //return Err(eyre!("Could not retrieve remote name from local branch"));
239                None
240            };
241
242        if let Some(remote) = upstream_remote_name_buf {
243            let remote_name = if let Some(name) = remote.as_str() {
244                Some(name.to_string())
245            } else {
246                return Err(eyre!("Remote name not valid utf-8"));
247            };
248
249            debug!("Remote name: {:?}", &remote_name);
250
251            Ok(remote_name)
252        } else {
253            Ok(None)
254        }
255    }
256
257    /// Returns the remote url after opening and validating repo from the local path
258    pub fn git_remote_from_path(path: &Path) -> Result<Option<String>> {
259        let r = GitRepo::to_repository_from_path(path)?;
260        GitRepoInfo::remote_url_from_repository(&r)
261    }
262
263    /// Returns the remote url from the `git2::Repository` struct
264    pub fn git_remote_from_repo(local_repo: &Repository) -> Result<Option<String>> {
265        GitRepoInfo::remote_url_from_repository(local_repo)
266    }
267
268    /// Returns a `Result<Option<Vec<PathBuf>>>` containing files changed between `commit1` and `commit2`
269    pub fn list_files_changed_between<S: AsRef<str>>(
270        &self,
271        commit1: S,
272        commit2: S,
273    ) -> Result<Option<Vec<PathBuf>>> {
274        let repo = self.to_repo();
275
276        let commit1 = self.expand_partial_commit_id(commit1.as_ref())?;
277        let commit2 = self.expand_partial_commit_id(commit2.as_ref())?;
278
279        let repo = repo.to_repository()?;
280
281        let oid1 = Oid::from_str(&commit1)?;
282        let oid2 = Oid::from_str(&commit2)?;
283
284        let git2_commit1 = repo.find_commit(oid1)?.tree()?;
285        let git2_commit2 = repo.find_commit(oid2)?.tree()?;
286
287        let diff = repo.diff_tree_to_tree(Some(&git2_commit1), Some(&git2_commit2), None)?;
288
289        let mut paths = Vec::new();
290
291        diff.print(git2::DiffFormat::NameOnly, |delta, _hunk, _line| {
292            let delta_path = if let Some(p) = delta.new_file().path() {
293                p
294            } else {
295                return false;
296            };
297
298            paths.push(delta_path.to_path_buf());
299            true
300        })
301        .wrap_err("File path not found in new commit to compare")?;
302
303        if !paths.is_empty() {
304            return Ok(Some(paths));
305        }
306
307        Ok(None)
308    }
309
310    /// Returns a `Result<Option<Vec<PathBuf>>>` containing files changed between `commit` and `commit~1` (the previous commit)
311    pub fn list_files_changed_at<S: AsRef<str>>(&self, commit: S) -> Result<Option<Vec<PathBuf>>> {
312        let repo = self.to_repo();
313
314        let commit = self.expand_partial_commit_id(commit.as_ref())?;
315
316        let git2_repo = repo.to_repository()?;
317
318        let oid = Oid::from_str(&commit)?;
319        let git2_commit = git2_repo.find_commit(oid)?;
320
321        let mut changed_files = Vec::new();
322
323        for parent in git2_commit.parents() {
324            let parent_commit_id = hex::encode(parent.id().as_bytes());
325
326            if let Some(path_vec) = self.list_files_changed_between(&parent_commit_id, &commit)? {
327                for p in path_vec {
328                    changed_files.push(p);
329                }
330            }
331        }
332
333        if !changed_files.is_empty() {
334            Ok(Some(changed_files))
335        } else {
336            Ok(None)
337        }
338    }
339
340    /// Takes in a partial commit SHA-1, and attempts to expand to the full 40-char commit id
341    pub fn expand_partial_commit_id<S: AsRef<str>>(&self, partial_commit_id: S) -> Result<String> {
342        let repo: GitRepo = self.to_repo();
343
344        // Don't need to do anything if the commit is already complete
345        // I guess the only issue is not validating it exists. Is that ok?
346        if partial_commit_id.as_ref().len() == 40 {
347            return Ok(partial_commit_id.as_ref().to_string());
348        }
349
350        // We can't reliably succeed if repo is a shallow clone
351        if repo.to_repository()?.is_shallow() {
352            return Err(eyre!(
353                "No support for partial commit id expand on shallow clones"
354            ));
355        }
356
357        let repo = repo.to_repository()?;
358
359        let extended_commit = hex::encode(
360            repo.revparse_single(partial_commit_id.as_ref())?
361                .peel_to_commit()?
362                .id()
363                .as_bytes(),
364        );
365
366        Ok(extended_commit)
367    }
368
369    /// Checks the list of files changed between last 2 commits (`HEAD` and `HEAD~1`).
370    /// Returns `bool` depending on whether any changes were made in `path`.
371    /// A `path` should be relative to the repo root. Can be a file or a directory.
372    pub fn has_path_changed<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
373        let repo = self.to_repo();
374        let git2_repo = repo.to_repository().wrap_err("Could not open repo")?;
375
376        // Get `HEAD~1` commit
377        // This could actually be multiple parent commits, if merge commit
378        let head = git2_repo
379            .head()
380            .wrap_err("Could not get HEAD ref")?
381            .peel_to_commit()
382            .wrap_err("Could not convert to commit")?;
383        let head_commit_id = hex::encode(head.id().as_bytes());
384        for commit in head.parents() {
385            let parent_commit_id = hex::encode(commit.id().as_bytes());
386
387            if self.has_path_changed_between(&path, &head_commit_id, &parent_commit_id)? {
388                return Ok(true);
389            }
390        }
391
392        Ok(false)
393    }
394
395    /// Checks the list of files changed between 2 commits (`commit1` and `commit2`).
396    /// Returns `bool` depending on whether any changes were made in `path`.
397    /// A `path` should be relative to the repo root. Can be a file or a directory.
398    pub fn has_path_changed_between<P: AsRef<Path>, S: AsRef<str>>(
399        &self,
400        path: P,
401        commit1: S,
402        commit2: S,
403    ) -> Result<bool> {
404        let commit1 = self
405            .expand_partial_commit_id(commit1.as_ref())
406            .wrap_err("Could not expand partial commit id for commit1")?;
407        let commit2 = self
408            .expand_partial_commit_id(commit2.as_ref())
409            .wrap_err("Could not expand partial commit id for commit2")?;
410
411        let changed_files = self
412            .list_files_changed_between(&commit1, &commit2)
413            .wrap_err("Error retrieving commit changes")?;
414
415        if let Some(files) = changed_files {
416            for f in files.iter() {
417                if f.to_str()
418                    .wrap_err("Couldn't convert pathbuf to str")?
419                    .starts_with(
420                        &path
421                            .as_ref()
422                            .to_path_buf()
423                            .to_str()
424                            .wrap_err("Couldn't convert pathbuf to str")?,
425                    )
426                {
427                    return Ok(true);
428                }
429            }
430        }
431
432        Ok(false)
433    }
434
435    /// Check if new commits exist by performing a shallow clone and comparing branch heads
436    pub fn new_commits_exist(&self) -> Result<bool> {
437        // Let's do a shallow clone behind the scenes using the same branch and creds
438        let repo = if let Ok(gitrepo) = GitRepo::new(self.url.to_string()) {
439            let branch = if let Some(branch) = self.branch.clone() {
440                branch
441            } else {
442                return Err(eyre!("No branch set"));
443            };
444
445            gitrepo
446                .with_branch(Some(branch))
447                .with_credentials(self.credentials.clone())
448        } else {
449            return Err(eyre!("Could not crete new GitUrl"));
450        };
451
452        let tempdir = if let Ok(dir) = Temp::new_dir() {
453            dir
454        } else {
455            return Err(eyre!("Could not create temporary dir"));
456        };
457
458        // We can do a shallow clone, because we only want the newest history
459        let clone: GitRepoCloneRequest = repo.into();
460        let repo = if let Ok(gitrepo) = clone.git_clone_shallow(tempdir) {
461            gitrepo
462        } else {
463            return Err(eyre!("Could not shallow clone dir"));
464        };
465
466        // If the HEAD commits don't match, we assume that `repo` is newer
467        Ok(self.head != repo.head)
468    }
469
470    /// Builds a `git2::RemoteCallbacks` using `self.credentials` to be used
471    /// in authenticated calls to a remote repo
472    pub fn build_git2_remotecallback(&self) -> Result<git2::RemoteCallbacks> {
473        if let Some(cred) = self.credentials.clone() {
474            debug!("Before building callback: {:?}", &cred);
475
476            match cred {
477                GitCredentials::SshKey {
478                    username,
479                    public_key,
480                    private_key,
481                    passphrase,
482                } => {
483                    let mut cb = git2::RemoteCallbacks::new();
484
485                    cb.credentials(
486                        move |_, _, _| match (public_key.clone(), passphrase.clone()) {
487                            (None, None) => {
488                                let key = if let Ok(key) =
489                                    Cred::ssh_key(&username, None, private_key.as_path(), None)
490                                {
491                                    key
492                                } else {
493                                    return Err(git2::Error::from_str(
494                                        "Could not create credentials object for ssh key",
495                                    ));
496                                };
497                                Ok(key)
498                            }
499                            (None, Some(pp)) => {
500                                let key = if let Ok(key) = Cred::ssh_key(
501                                    &username,
502                                    None,
503                                    private_key.as_path(),
504                                    Some(pp.as_ref()),
505                                ) {
506                                    key
507                                } else {
508                                    return Err(git2::Error::from_str(
509                                        "Could not create credentials object for ssh key",
510                                    ));
511                                };
512                                Ok(key)
513                            }
514                            (Some(pk), None) => {
515                                let key = if let Ok(key) = Cred::ssh_key(
516                                    &username,
517                                    Some(pk.as_path()),
518                                    private_key.as_path(),
519                                    None,
520                                ) {
521                                    key
522                                } else {
523                                    return Err(git2::Error::from_str(
524                                        "Could not create credentials object for ssh key",
525                                    ));
526                                };
527                                Ok(key)
528                            }
529                            (Some(pk), Some(pp)) => {
530                                let key = if let Ok(key) = Cred::ssh_key(
531                                    &username,
532                                    Some(pk.as_path()),
533                                    private_key.as_path(),
534                                    Some(pp.as_ref()),
535                                ) {
536                                    key
537                                } else {
538                                    return Err(git2::Error::from_str(
539                                        "Could not create credentials object for ssh key",
540                                    ));
541                                };
542                                Ok(key)
543                            }
544                        },
545                    );
546
547                    Ok(cb)
548                }
549                GitCredentials::UserPassPlaintext { username, password } => {
550                    let mut cb = git2::RemoteCallbacks::new();
551                    cb.credentials(move |_, _, _| {
552                        Cred::userpass_plaintext(username.as_str(), password.as_str())
553                    });
554
555                    Ok(cb)
556                }
557            }
558        } else {
559            // No credentials. Repo is public
560            Ok(git2::RemoteCallbacks::new())
561        }
562    }
563}