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*/