git_meta/
repo.rs

1use std::fmt::Debug;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::{GitCommitMeta, GitCredentials, GitRepo, GitRepoCloneRequest, GitRepoInfo};
6use git_url_parse::GitUrl;
7
8use git2::{Branch, Commit, Repository};
9
10use color_eyre::eyre::{eyre, Result};
11use tracing::debug;
12
13impl GitRepo {
14    /// Returns a `GitRepo` after parsing metadata from a repo
15    /// - If a local `branch` is not provided, current checked out branch will be used.
16    ///   The provided branch will be resolved to its remote branch name
17    /// - If `commit_id` is not provided, the current commit (the HEAD of `branch`) will be used
18    pub fn open(path: PathBuf, branch: Option<String>, commit_id: Option<String>) -> Result<Self> {
19        // First we open the repository and get the remote_url and parse it into components
20        let local_repo = Self::to_repository_from_path(path.clone())?;
21        let remote_url = GitRepoInfo::git_remote_from_repo(&local_repo)?;
22
23        // Resolve the remote branch name, if possible
24        let working_branch_name =
25            if let Ok(Some(git2_branch)) = GitRepoInfo::get_git2_branch(&local_repo, &branch) {
26                git2_branch.name()?.map(str::to_string)
27            } else {
28                // Detached HEAD
29                None
30            };
31
32        // We don't support digging around in past commits if the repo is shallow
33        if let Some(_c) = &commit_id {
34            if local_repo.is_shallow() {
35                return Err(eyre!("Can't open by commit on shallow clones"));
36            }
37        }
38
39        // This is essential for when we're in Detatched HEAD
40        let commit = Self::get_git2_commit(&local_repo, &working_branch_name, &commit_id)?;
41
42        if let Some(url) = remote_url {
43            Ok(Self::new(url)?
44                .with_path(path)?
45                .with_branch(working_branch_name)
46                .with_git2_commit(commit))
47        } else {
48            // Use this when the current branch has no remote ref
49            let file_path = path.as_os_str().to_str().unwrap_or_default();
50            Ok(Self::new(file_path)?
51                .with_path(path)?
52                .with_branch(working_branch_name)
53                .with_git2_commit(commit))
54        }
55    }
56
57    /// Set the location of `GitRepo` on the filesystem
58    pub fn with_path(mut self, path: PathBuf) -> Result<Self> {
59        // We want to get the absolute path of the directory of the repo
60        self.path = if let Ok(p) = fs::canonicalize(path) {
61            Some(p)
62        } else {
63            return Err(eyre!("Directory was not found"));
64        };
65        Ok(self)
66    }
67
68    /// Intended to be set with the remote name branch of GitRepo
69    pub fn with_branch(mut self, branch: Option<String>) -> Self {
70        if let Some(b) = branch {
71            self.branch = Some(b);
72        }
73        self
74    }
75
76    /// Reinit `GitRepo` with commit id
77    pub fn with_commit(mut self, commit_id: Option<String>) -> Result<Self> {
78        self = if let Some(path) = self.path {
79            if let Ok(repo) = Self::open(path, self.branch, commit_id) {
80                repo
81            } else {
82                return Err(eyre!("Unable to open GitRepo with commit id"));
83            }
84        } else {
85            return Err(eyre!("No path to GitRepo set"));
86        };
87        Ok(self)
88    }
89
90    /// Set the `GitCommitMeta` from `git2::Commit`
91    pub fn with_git2_commit(mut self, commit: Option<Commit>) -> Self {
92        match commit {
93            Some(c) => {
94                let commit_msg = c.message().unwrap_or_default().to_string();
95
96                let commit = GitCommitMeta::new(c.id())
97                    .with_message(Some(commit_msg))
98                    .with_timestamp(c.time().seconds());
99
100                self.head = Some(commit);
101                self
102            }
103            None => {
104                self.head = None;
105                self
106            }
107        }
108    }
109
110    /// Set `GitCredentials` for private repos.
111    /// `None` indicates public repo
112    pub fn with_credentials(mut self, creds: Option<GitCredentials>) -> Self {
113        self.credentials = creds;
114        self
115    }
116
117    /// Create a new `GitRepo` with `url`.
118    /// Use along with `with_*` methods to set other fields of `GitRepo`.
119    /// Use `GitRepoCloner` if you need to clone the repo, and convert back with `GitRepo.into()`
120    pub fn new<S: AsRef<str>>(url: S) -> Result<Self> {
121        let url = if let Ok(url) = GitUrl::parse(url.as_ref()) {
122            url
123        } else {
124            return Err(eyre!("url failed to parse as GitUrl"));
125        };
126
127        Ok(Self {
128            url,
129            credentials: None,
130            head: None,
131            branch: None,
132            path: None,
133        })
134    }
135
136    pub fn to_clone(&self) -> GitRepoCloneRequest {
137        self.into()
138    }
139
140    pub fn to_info(&self) -> GitRepoInfo {
141        self.into()
142    }
143
144    /// Returns a `git2::Repository` from `self.path`
145    pub fn to_repository(&self) -> Result<Repository> {
146        if let Some(path) = self.path.as_ref() {
147            Ok(Self::to_repository_from_path(path.as_os_str())?)
148        } else {
149            Err(eyre!("No path set to open"))
150        }
151    }
152
153    /// Returns a `git2::Repository` from a given repo directory path
154    pub fn to_repository_from_path<P: AsRef<Path> + Debug>(path: P) -> Result<Repository> {
155        if let Ok(repo) = Repository::open(path.as_ref().as_os_str()) {
156            Ok(repo)
157        } else {
158            Err(eyre!("Failed to open repo at {path:#?}"))
159        }
160    }
161
162    /// Return a `git2::Commit` that refers to the commit object requested for building
163    /// If commit id is not provided, then we'll use the HEAD commit of whatever branch is active or provided
164    fn get_git2_commit<'repo>(
165        r: &'repo Repository,
166        branch: &Option<String>,
167        commit_id: &Option<String>,
168    ) -> Result<Option<Commit<'repo>>> {
169        // If branch or commit not given, return the HEAD of `r`
170        if let (None, None) = (branch, commit_id) {
171            // Do I need to verify that we're in detached head?
172            // if r.head_detached()? {}
173
174            if let Ok(commit) = r.head()?.peel_to_commit() {
175                return Ok(Some(commit));
176            } else {
177                return Err(eyre!(
178                    "Unable to retrieve HEAD commit object from remote branch"
179                ));
180            }
181        }
182
183        match commit_id {
184            Some(id) => {
185                debug!("Commit provided. Using {}", id);
186                let commit = r.find_commit(git2::Oid::from_str(id)?)?;
187
188                // TODO: Verify if the commit is in the branch. If not, return Ok(None)
189                // Do we care about detatched HEAD?
190                //let _ = GitRepo::is_commit_in_branch(
191                //    r,
192                //    &commit,
193                //    &Branch::wrap(working_branch.into_reference()),
194                //);
195
196                Ok(Some(commit))
197            }
198
199            // We want the HEAD of the remote branch (as opposed to the working branch)
200            None => {
201                debug!("No commit provided. Attempting to use HEAD commit from remote branch");
202
203                if branch.is_some() {
204                    if let Ok(Some(git2_branch)) = GitRepoInfo::get_git2_branch(r, branch) {
205                        match git2_branch.upstream() {
206                            Ok(upstream_branch) => {
207                                let working_ref = upstream_branch.into_reference();
208
209                                let commit = if let Ok(commit) = working_ref.peel_to_commit() {
210                                    commit
211                                } else {
212                                    return Err(eyre!(
213                                        "Unable to retrieve HEAD commit object from remote branch"
214                                    ));
215                                };
216
217                                let _ = GitRepoInfo::is_commit_in_branch(
218                                    r,
219                                    &commit,
220                                    &Branch::wrap(working_ref),
221                                );
222
223                                Ok(Some(commit))
224                            }
225                            // This match-arm supports branches that are local-only
226                            Err(_e) => {
227                                debug!(
228                                    "No remote branch found. Using HEAD commit from local branch"
229                                );
230                                let working_ref = git2_branch.into_reference();
231
232                                let commit = if let Ok(commit) = working_ref.peel_to_commit() {
233                                    commit
234                                } else {
235                                    return Err(eyre!(
236                                        "Unable to retrieve HEAD commit object from remote branch"
237                                    ));
238                                };
239
240                                let _ = GitRepoInfo::is_commit_in_branch(
241                                    r,
242                                    &commit,
243                                    &Branch::wrap(working_ref),
244                                );
245
246                                Ok(Some(commit))
247                            }
248                        }
249                    } else {
250                        // This happens if the branch doesn't exist. Should this be Err()?
251                        Ok(None)
252                    }
253                } else {
254                    unreachable!("We should have returned Err() early if both commit and branch not provided. We need one.")
255                }
256            }
257        }
258    }
259
260    /// Test whether `GitRepo` is a shallow clone
261    pub fn is_shallow(&self) -> Result<bool> {
262        let repo = self.to_repository()?;
263        Ok(repo.is_shallow())
264    }
265}