projvar/tools/
git.rs

1// SPDX-FileCopyrightText: 2021 Robin Vobruba <hoijui.quaero@gmail.com>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5use chrono::DateTime;
6use git2::{self, Repository};
7use regex::Regex;
8use std::convert::TryFrom;
9use std::path::Path;
10use std::path::PathBuf;
11use std::str;
12use std::sync::LazyLock;
13use thiserror::Error;
14
15use crate::var::Key;
16
17/// This enumerates all possible errors returned by this module.
18/// Represents all other cases of `std::io::Error`.
19#[derive(Error, Debug)]
20#[error("Git2 lib error: {from} - {message}")]
21pub struct Error {
22    from: git2::Error,
23    message: String,
24}
25
26impl From<&str> for Error {
27    fn from(message: &str) -> Self {
28        Self {
29            from: git2::Error::from_str("PLACEHOLDER"),
30            message: String::from(message),
31        }
32    }
33}
34
35/// The default date format.
36/// For formatting specifiers, see:
37/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
38pub const DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
39
40/// These are the protocols that git supports for transportation,
41/// i.e. when cloning, fetching and pushing.
42/// Documentation:
43/// <https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols>
44#[derive(Clone, Copy)]
45pub enum TransferProtocol {
46    /// Gits own, fully anonymous/un-authenticated protocol
47    /// Documentation:
48    /// <https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_git_protocol>
49    /// Example:
50    /// `"git://repo.or.cz/girocco.git"`
51    Git,
52    /// HTTP(S) - Hyper-Text Transfer Protocol (Secure)
53    /// Documentation:
54    /// <https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_http_protocols>
55    /// Example:
56    /// `"https://gitlab.com/hoijui/kicad-text-injector.git"`
57    Https,
58    /// SSH - **S**ecure **Sh**ell
59    /// Documentation:
60    /// <https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_ssh_protocol>
61    /// Example:
62    /// `"git@gitlab.com/hoijui/kicad-text-injector.git"`
63    // /// ssh://gitlab.com/hoijui/kicad-text-injector.git
64    Ssh,
65}
66
67impl TransferProtocol {
68    #[must_use]
69    pub const fn scheme_str(self) -> &'static str {
70        match self {
71            Self::Git => "git",
72            Self::Https => "https",
73            Self::Ssh => "ssh",
74        }
75    }
76
77    #[must_use]
78    pub const fn to_clone_url_key(self) -> Key {
79        match self {
80            Self::Git => Key::RepoCloneUrlGit,
81            Self::Https => Key::RepoCloneUrlHttp,
82            Self::Ssh => Key::RepoCloneUrlSsh,
83        }
84    }
85}
86
87/// Checks whether a given version string is a git broken version.
88/// Broken means, the repository is corrupt,
89/// and Git cannot determine if there is local modification.
90#[must_use]
91pub fn is_git_broken_version(vers: &str) -> bool {
92    static R_BROKEN_VERSION: LazyLock<Regex> =
93        LazyLock::new(|| Regex::new(r"^[^-].+(-dirty)?-broken(-.+)?$").unwrap());
94    R_BROKEN_VERSION.is_match(vers)
95}
96
97/// Checks whether a given version string is a git dirty version.
98/// Dirty means, there are uncommitted changes.
99#[must_use]
100pub fn is_git_dirty_version(vers: &str) -> bool {
101    static R_DIRTY_VERSION: LazyLock<Regex> =
102        LazyLock::new(|| Regex::new(r"^[^-].+(-broken)?-dirty(-.+)?$").unwrap());
103    R_DIRTY_VERSION.is_match(vers)
104}
105
106/// Returns true if the repo contains any tags.
107fn _has_tags(repo: &git2::Repository) -> bool {
108    let mut has_tags = false;
109    let _ = repo.tag_foreach(|_, _| {
110        has_tags = true;
111        false
112    });
113    has_tags
114}
115
116/// Returns the result of `git describe` with options:
117/// - "--tags"
118/// - "--dirty"
119/// - MISSING: "--always" (not possible)
120///   You should handle this case external to this function,
121///   by using a (shortened-)hash, if this function returns `Err`.
122/// - MISSING: "--broken"
123///   We might also want this,
124//    which is not possible with git2-rs,
125//    but it is really not important.
126fn _version(repo: &git2::Repository) -> Result<String, Error> {
127    repo.describe(
128        git2::DescribeOptions::new()
129            .pattern("*[0-9]*.[0-9]*.[0-9]*")
130            .describe_tags(),
131    )
132    .map_err(|from| Error {
133        from,
134        message: String::from("Failed to describe the HEAD revision version"),
135    })?
136    .format(Some(
137        git2::DescribeFormatOptions::new()
138            .always_use_long_format(false)
139            .dirty_suffix("-dirty"),
140    ))
141    .map_err(|from| Error {
142        from,
143        message: String::from("Failed to format the HEAD revision version"),
144    })
145}
146
147pub struct Repo {
148    repo: git2::Repository,
149}
150
151impl TryFrom<Option<&str>> for Repo {
152    type Error = git2::Error;
153    fn try_from(repo_root: Option<&str>) -> Result<Self, Self::Error> {
154        let repo = Repository::open(repo_root.unwrap_or("."))?;
155        Ok(Self { repo })
156    }
157}
158
159impl TryFrom<Option<&Path>> for Repo {
160    type Error = git2::Error;
161    fn try_from(repo_root: Option<&Path>) -> Result<Self, Self::Error> {
162        let repo = Repository::open(repo_root.unwrap_or_else(|| Path::new(".")))?;
163        Ok(Self { repo })
164    }
165}
166
167impl Repo {
168    // pub fn new(repo_root: Option<&str>) -> BoxResult<Repo> {
169    //     let repo_root = repo_root.unwrap_or(".");
170    //     Ok(Repo {
171    //         repo: Repository::open(repo_root)?,
172    //     })
173    // }
174
175    // pub fn new(repo_root: Option<&str>) -> BoxResult<Repo> {
176    //     let repo_root = repo_root.unwrap_or(".");
177    //     Ok(Repo {
178    //         repo: Repository::open(repo_root)?,
179    //     })
180    // }
181
182    #[must_use]
183    pub const fn inner(&self) -> &git2::Repository {
184        &self.repo
185    }
186
187    /// Returns the path to the local repo.
188    ///
189    /// # Panics
190    ///
191    /// Should never happen
192    #[must_use]
193    pub fn local_path(&self) -> PathBuf {
194        let path = self.repo.path().canonicalize().unwrap(); // We want this to panic, as it should never happen
195        match path.file_name() {
196            Some(file_name) => {
197                if file_name.to_str().unwrap() == ".git" {
198                    // This panics if not valid UTF-8
199                    path.parent().unwrap().to_path_buf() // As we already know the parent is called ".git", this could never panic
200                } else {
201                    // let path_str = path as &str;
202                    // (path.as_ref() as &Path).clone()
203                    // Path::new(path_str)
204                    path
205                }
206            }
207            None => {
208                // There is no file_name in the path, so it must be the root of the file-system
209                Path::new("/").to_path_buf()
210            }
211        }
212    }
213
214    /// Returns the path to the local repo as string.
215    ///
216    /// # Panics
217    ///
218    /// Should never happen
219    #[must_use]
220    pub fn local_path_str(&self) -> String {
221        // The `.unwrap()` is safe here,
222        // because we already know from within `local_path()`,
223        // that it is valid UTF-8
224        self.local_path().to_str().unwrap().to_owned()
225    }
226
227    fn _branch(&self) -> Result<Option<git2::Branch<'_>>, Error> {
228        let head_ref = self.repo.head().map_err(|from| Error {
229            from,
230            message: String::from("Failed to convert HEAD into a branch"),
231        })?;
232        Ok(if head_ref.is_branch() {
233            Some(git2::Branch::wrap(head_ref))
234        } else {
235            log::warn!(
236                "Failed to get the current branch.
237This may indicate either:
238* valid: No branch is checked out
239  -> HEAD is pointing to a commit or a tag
240* problem: You are running on CI,
241  and while it should have a branch checked out,
242  it has not.
243  This may happen with shallow repos,
244  see for example GitLab bug
245  <https://gitlab.com/gitlab-org/gitlab/-/issues/350100>."
246            );
247            None
248        })
249    }
250
251    /// Returns the SHA of the currently checked-out commit,
252    /// if any.
253    //
254    /// # Errors
255    ///
256    /// If some git-related magic goes south,
257    /// or there is no commit.
258    pub fn sha(&self) -> Result<Option<String>, Error> {
259        let head = self.repo.head().map_err(|from| Error {
260            from,
261            message: String::from("Failed to get repo HEAD for figuring out the SHA1"),
262        })?;
263        Ok(
264            //Some(
265            head.resolve()
266                .map_err(|from| Error {
267                    from,
268                    message: String::from("Failed resolving HEAD into a direct reference"),
269                })?
270                .target()
271                .map(|oid| oid.to_string()),
272        ) //)
273    }
274
275    /// Returns the local name of the currently checked-out branch,
276    /// if any.
277    //
278    /// # Errors
279    ///
280    /// If some git-related magic goes south,
281    /// or the branch name is not valid UTF-8.
282    pub fn branch(&self) -> Result<Option<String>, Error> {
283        Ok(if let Some(branch) = self._branch()? {
284            Some(
285                branch
286                    .name()
287                    .map_err(|from| Error {
288                        from,
289                        message: String::from("Failed fetching name of a branch"),
290                    })?
291                    .ok_or_else(|| Error::from("Branch name is not UTF-8 compatible"))?
292                    .to_owned(),
293            )
294        } else {
295            None
296        })
297    }
298
299    fn _tag(&self) -> Result<Option<String>, Error> {
300        let head = self.repo.head().map_err(|from| Error {
301            from,
302            message: String::from("Failed to get repo HEAD for figuring out the tag"),
303        })?;
304        let head_oid = head
305            .resolve()
306            .map_err(|from| Error {
307                from,
308                message: String::from("Failed resolve HEAD into a reference"),
309            })?
310            .target()
311            .ok_or_else(|| git2::Error::from_str("No OID for HEAD"))
312            .map_err(|from| Error {
313                from,
314                message: String::from("-"),
315            })?;
316        let mut tag = None;
317        let mut inner_err: Option<Result<Option<String>, Error>> = None;
318        self.repo
319            .tag_foreach(|_id, name| {
320                let name_str = String::from_utf8(name.to_vec())
321                    .expect("Failed to convert tag name to UTF-8 string");
322                let cur_tag_res = self.repo.find_reference(&name_str).and_then(|git_ref| {
323                    git_ref.target().ok_or_else(|| {
324                        git2::Error::from_str("Failed to get tag reference target commit")
325                    })
326                });
327                let cur_tag = match cur_tag_res {
328                    Err(from) => {
329                        inner_err = Some(Err(Error {
330                            from,
331                            message: String::from("Failed fetching current tag reference"),
332                        }));
333                        return false;
334                    }
335                    Ok(cur_tag) => cur_tag,
336                };
337                if cur_tag == head_oid {
338                    tag = Some(name_str);
339                    false
340                } else {
341                    true
342                }
343            })
344            .map_err(|from| Error {
345                from,
346                message: String::from("Failed processing tags"),
347            })?;
348        match inner_err {
349            Some(err) => err,
350            None => Ok(tag),
351        }
352    }
353
354    /// Returns the name of the currently checked-out tag,
355    /// if any tag points to the current HEAD.
356    //
357    /// # Errors
358    ///
359    /// If some git-related magic goes south,
360    /// or the tag name is not valid UTF-8.
361    pub fn tag(&self) -> Result<Option<String>, Error> {
362        self._tag()
363    }
364
365    fn _remote_tracking_branch(&self) -> Result<Option<git2::Branch<'_>>, Error> {
366        if let Some(branch) = self._branch()? {
367            match branch.upstream() {
368                Ok(remote_branch) => Ok(Some(remote_branch)),
369                Err(from) => {
370                    if from.code() == git2::ErrorCode::NotFound
371                    /*&& from.class() == git2::ErrorClass::Config*/
372                    {
373                        // NOTE It is totally normal for a branch not to have a remote-tracking-branch;
374                        //      no reason to return an error.
375                        Ok(None)
376                    } else {
377                        Err(Error {
378                            from,
379                            message: String::from("Failed resolving the remote tracking branch"),
380                        })
381                    }
382                }
383            }
384        } else {
385            Ok(None)
386        }
387    }
388
389    /// The local name of the remote tracking branch.
390    //
391    /// # Errors
392    ///
393    /// If some git-related magic goes south,
394    /// or the remote name is not valid UTF-8.
395    pub fn remote_tracking_branch(&self) -> Result<Option<String>, Error> {
396        Ok(
397            if let Some(remote_tracking_branch) = self._remote_tracking_branch()? {
398                Some(
399                    remote_tracking_branch
400                        .name()
401                        .map_err(|from| Error {
402                            from,
403                            message: String::from(
404                                "Failed fetching the remote tracking branch name",
405                            ),
406                        })?
407                        .ok_or_else(|| {
408                            Error::from("Remote tracking branch name is not UTF-8 compatible")
409                        })?
410                        .to_owned(),
411                )
412            } else {
413                None
414            },
415        )
416    }
417
418    /// Local name of the main remote.
419    //
420    /// # Errors
421    ///
422    /// If some git-related magic goes south,
423    /// or the reomte name is not valid UTF-8.
424    pub fn remote_name(&self) -> Result<Option<String>, Error> {
425        Ok(
426            if let Some(remote_tracking_branch) = self.remote_tracking_branch()? {
427                Some(self
428                .repo
429                .branch_remote_name(
430                    self.repo
431                        .resolve_reference_from_short_name(&remote_tracking_branch)
432                        .map_err(|from| Error {
433                            from,
434                            message: String::from(
435                                "Failed to resolve reference from remote-tracking branch short name",
436                            ),
437                        })?
438                        .name()
439                        .ok_or_else(|| Error::from("Remote branch name is not UTF-8 compatible"))?,
440                )
441                .map_err(|from| Error {
442                    from,
443                    message: String::from("Failed to get branch remote name"),
444                })?
445                .as_str()
446                .ok_or_else(|| Error::from("Remote name is not UTF-8 compatible"))?
447                .to_owned())
448            } else {
449                None
450            },
451        )
452        // let remote = remote_tracking_branch.name(); // HACK Need to split of the name part, as this is probably origin/master, and we want only origin.
453    }
454
455    /// Returns the clone URL of the main remote,
456    /// if there is any.
457    //
458    /// # Errors
459    ///
460    /// If some git-related magic goes south.
461    pub fn remote_clone_url(&self) -> Result<Option<String>, Error> {
462        Ok(if let Some(remote_name) = self.remote_name()? {
463            Some(
464                self.repo
465                    .find_remote(&remote_name)
466                    .map_err(|from| Error {
467                        from,
468                        message: String::from("Failed to find remote name for remote clone URL"),
469                    })?
470                    .url()
471                    .ok_or_else(|| Error::from("Remote URL is not UTF-8 compatible"))?
472                    .to_owned(),
473            )
474        } else {
475            None
476        })
477    }
478
479    /// Returns the version of the current state of the repo.
480    /// This is basically the result of "git describe --tags --all <and-some-more...>".
481    ///
482    ///
483    /// # Errors
484    ///
485    /// If some git-related magic goes south.
486    pub fn version(&self) -> Result<String, Error> {
487        if _has_tags(&self.repo) {
488            _version(&self.repo)
489        } else {
490            log::warn!(
491                "The git repository has no tags.
492Please consider adding at least a tag '0.1.0' to the first commit of the repo history; \
493for example with:
494git tag -a -m 'Release 0.1.0' 0.1.0 $(git rev-list --max-parents=0 HEAD)"
495            );
496            match self.sha()? {
497                Some(sha_str) => Ok(sha_str),
498                None => Err(Error::from(
499                    "The repo has no tags, so we can not use git describe, \
500and there is no commit checked out either",
501                )),
502            }
503        }
504    }
505
506    /// Returns the commit-time (not author-time)
507    /// of the last commit in the currently checked out history (=> HEAD)
508    ///
509    /// # Errors
510    ///
511    /// If some git-related magic goes south.
512    pub fn commit_date(&self, date_format: &str) -> Result<String, Error> {
513        let head = self.repo.head().map_err(|from| Error {
514            from,
515            message: String::from("Failed to get repo HEAD for figuring out the commit date"),
516        })?;
517        let commit_time_git2 = head
518            .peel_to_commit()
519            .map_err(|from| Error {
520                from,
521                message: String::from(
522                    "Failed to peal HEAD to commit for figuring out the commit date",
523                ),
524            })?
525            .time();
526        let commit_time_chrono = DateTime::from_timestamp(commit_time_git2.seconds(), 0)
527            .ok_or_else(|| {
528                Error::from("Failed to peal HEAD to commit for figuring out the commit date")
529            })?;
530        Ok(commit_time_chrono.format(date_format).to_string())
531        // date.fromtimestamp(repo.head.ref.commit.committed_date).strftime(date_format)
532    }
533}
534
535/*
536#[cfg(test)]
537mod tests {
538    // Note this useful idiom:
539    // importing names from outer (for mod tests) scope.
540    use super::*;
541
542    macro_rules! is_that_error {
543        ($result:ident,$err_type:ident) => {
544            $result.unwrap_err().downcast_ref::<$err_type>().is_some()
545        };
546    }
547
548    #[test]
549    fn test_is_git_dirty_version() {
550        assert!(!is_git_dirty_version("0.2.2"));
551        assert!(!is_git_dirty_version("0.2.2-0-gbe4cc26"));
552        assert!(!is_git_dirty_version("dirty"));
553        assert!(!is_git_dirty_version("-dirty"));
554        assert!(!is_git_dirty_version("-dirty-broken"));
555        assert!(!is_git_dirty_version("-broken-dirty"));
556        assert!(is_git_dirty_version("0.2.2-0-gbe4cc26-dirty"));
557        assert!(is_git_dirty_version("0.2.2-0-gbe4cc26-dirty-broken"));
558    }
559
560    #[test]
561    fn test_web_to_build_hosting_url() {
562        assert_eq!(
563            web_to_build_hosting_url("https://gitlab.com/OSEGermany/OHS-3105/").unwrap(),
564            "https://osegermany.gitlab.io/OHS-3105"
565        );
566        assert_eq!(
567            web_to_build_hosting_url("https://github.com/hoijui/escher").unwrap(),
568            "https://hoijui.github.io/escher"
569        );
570
571        let result = web_to_build_hosting_url("git@github.com:hoijui/escher.git");
572        assert!(is_that_error!(result, UrlConversionError));
573    }
574}
575*/