gw_bin/checks/
git.rs

1use self::repository::GitRepository;
2use super::{Check, CheckError};
3use crate::context::Context;
4use std::fmt::{Debug, Display, Formatter};
5use thiserror::Error;
6
7mod config;
8mod credentials;
9mod known_hosts;
10mod repository;
11
12use config::setup_gitconfig;
13pub use credentials::CredentialAuth;
14use known_hosts::setup_known_hosts;
15use log::warn;
16use repository::shorthash;
17
18const CHECK_NAME: &str = "GIT";
19
20#[derive(Clone, Debug)]
21pub enum GitTriggerArgument {
22    Push,
23    Tag(String),
24}
25
26impl Display for GitTriggerArgument {
27    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
28        match self {
29            GitTriggerArgument::Push => f.write_str("push"),
30            GitTriggerArgument::Tag(pattern) => {
31                if pattern == "*" {
32                    f.write_str("tag")
33                } else {
34                    write!(f, "tag matching \"{pattern}\"")
35                }
36            }
37        }
38    }
39}
40
41/// A check to fetch and pull a local git repository.
42///
43/// This will update the repository, if there are any changes.
44/// In case there are any local changes, these might be erased.
45pub struct GitCheck {
46    pub repo: GitRepository,
47    pub trigger: GitTriggerArgument,
48}
49
50/// A custom error describing the error cases for the GitCheck.
51#[derive(Debug, Error)]
52pub enum GitError {
53    /// The directory is not a valid git repository.
54    #[error("{0} is not a valid git repository ({1})")]
55    NotAGitRepository(String, String),
56    /// Cannot parse HEAD, either stuck an unborn branch or some deleted reference
57    #[error("HEAD is invalid, probably points to invalid commit")]
58    NoHead,
59    /// There is no branch in the repository currently. It can be a repository
60    /// without any branch, or checked out on a commit.
61    #[error("repository is not on a branch, checkout or create a commit first")]
62    NotOnABranch,
63    /// There is no remote for the current branch. This usually because the branch hasn't been pulled.
64    #[error("branch {0} doesn't have a remote, push your commits first")]
65    NoRemoteForBranch(String),
66    /// There are changes in the directory, avoiding pulling. This is a safety mechanism to avoid pulling
67    /// over local changes, to not overwrite anything important.
68    #[error("there are uncommited changes in the directory")]
69    DirtyWorkingTree,
70    /// Cannot load the git config
71    #[error("cannot load git config")]
72    ConfigLoadingFailed,
73    /// Cannot create the ssh config
74    #[error("cannot create ssh config")]
75    SshConfigFailed,
76    /// Cannot fetch the current branch. This can be a network failure, authentication error or many other things.
77    #[error("cannot fetch ({0})")]
78    FetchFailed(String),
79    /// Cannot pull updates to the current branch. This means either the merge analysis failed
80    /// or there is a merge conflict.
81    #[error("cannot update branch, this is likely a merge conflict")]
82    MergeConflict,
83    /// Failed finding tags between the fetched commit and head. This might be a
84    #[error("failed matching tags between the new commit and the branch")]
85    TagMatchingFailed,
86    /// Cannot set the HEAD to the fetch commit.
87    #[error("could not set HEAD to fetch commit {0}")]
88    FailedSettingHead(String),
89}
90
91impl From<GitError> for CheckError {
92    fn from(value: GitError) -> Self {
93        match value {
94            GitError::NotAGitRepository(_, _)
95            | GitError::NoHead
96            | GitError::NotOnABranch
97            | GitError::NoRemoteForBranch(_) => CheckError::Misconfigured(value.to_string()),
98            GitError::ConfigLoadingFailed | GitError::SshConfigFailed => {
99                CheckError::PermissionDenied(value.to_string())
100            }
101            GitError::DirtyWorkingTree | GitError::MergeConflict => {
102                CheckError::Conflict(value.to_string())
103            }
104            GitError::FetchFailed(_)
105            | GitError::FailedSettingHead(_)
106            | GitError::TagMatchingFailed => CheckError::FailedUpdate(value.to_string()),
107        }
108    }
109}
110
111impl GitCheck {
112    /// Open the git repository at the given directory.
113    pub fn open_inner(directory: &str, trigger: GitTriggerArgument) -> Result<Self, CheckError> {
114        let repo = GitRepository::open(directory)?;
115
116        if let GitTriggerArgument::Tag(p) = &trigger {
117            if !(p.contains('*') || p.contains('?') || p.contains('[') || p.contains('{')) {
118                warn!("The tag pattern does not contain any globbing (*, ?, [] or {{}}), so it will only match \"{p}\" exactly.");
119            }
120        }
121
122        Ok(GitCheck { repo, trigger })
123    }
124
125    pub fn open(
126        directory: &str,
127        additional_host: Option<String>,
128        trigger: GitTriggerArgument,
129    ) -> Result<Self, CheckError> {
130        let known_hosts_failed = setup_known_hosts(additional_host).is_err();
131        let gitconfig_failed = setup_gitconfig(directory).is_err();
132        if known_hosts_failed || gitconfig_failed {
133            warn!("Setting up known hosts or git configuration failed. Check if home directory exists and the permissions are correct.");
134        };
135
136        GitCheck::open_inner(directory, trigger)
137    }
138
139    pub fn set_auth(&mut self, auth: CredentialAuth) {
140        self.repo.set_auth(auth);
141    }
142
143    fn check_inner(&mut self, context: &mut Context) -> Result<bool, GitError> {
144        let GitCheck { repo, trigger } = self;
145
146        // Load context data from repository information
147        let information = repo.get_repository_information()?;
148        context.insert("CHECK_NAME", CHECK_NAME.to_string());
149        context.insert("GIT_BRANCH_NAME", information.branch_name);
150        context.insert("GIT_BEFORE_COMMIT_SHA", information.commit_sha.to_string());
151        context.insert("GIT_BEFORE_COMMIT_SHORT_SHA", information.commit_short_sha);
152        context.insert("GIT_REMOTE_NAME", information.remote_name);
153        context.insert("GIT_REMOTE_URL", information.remote_url);
154
155        // Pull repository contents and report
156        let fetch_commit = repo.fetch()?;
157        if repo.check_if_updatable(&fetch_commit)? {
158            match trigger {
159                GitTriggerArgument::Push => {
160                    repo.pull(fetch_commit.id())?;
161                    context.insert("GIT_REF_TYPE", "branch".to_string());
162                    context.insert("GIT_REF_NAME", information.ref_name);
163                    context.insert("GIT_COMMIT_SHA", fetch_commit.id().to_string());
164                    context.insert("GIT_COMMIT_SHORT_SHA", shorthash(&fetch_commit.id()));
165                    Ok(true)
166                }
167                GitTriggerArgument::Tag(pattern) => {
168                    let mut tags = repo.find_tags(fetch_commit.id(), pattern)?;
169                    if let Some((tag_name, commit)) = tags.pop() {
170                        repo.pull(commit)?;
171                        context.insert("GIT_REF_TYPE", "tag".to_string());
172                        context.insert("GIT_REF_NAME", format!("refs/tags/{tag_name}"));
173                        context.insert("GIT_COMMIT_SHA", commit.to_string());
174                        context.insert("GIT_COMMIT_SHORT_SHA", shorthash(&commit));
175                        context.insert("GIT_COMMIT_TAG_NAME", tag_name.to_string());
176                        Ok(true)
177                    } else {
178                        Ok(false)
179                    }
180                }
181            }
182        } else {
183            Ok(false)
184        }
185    }
186}
187
188impl Check for GitCheck {
189    /// Fetch and pull changes from the remote repository on the current branch.
190    /// It returns true if the pull was successful and there are new changes.
191    fn check(&mut self, context: &mut Context) -> Result<bool, CheckError> {
192        let update_successful = self.check_inner(context)?;
193
194        Ok(update_successful)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use duct::cmd;
202    use rand::distr::{Alphanumeric, SampleString};
203    use std::{collections::HashMap, error::Error, fs, path::Path};
204
205    fn get_random_id() -> String {
206        Alphanumeric.sample_string(&mut rand::rng(), 16)
207    }
208
209    fn create_empty_repository(local: &str) -> Result<(), Box<dyn Error>> {
210        let remote = format!("{local}-remote");
211
212        // Create directory and repository in it
213        fs::create_dir(&remote)?;
214        cmd!("git", "init", "--bare").dir(&remote).read()?;
215        cmd!("git", "clone", &remote, &local).read()?;
216        create_commit(local, "1", "1")?;
217        push_all(local)?;
218
219        Ok(())
220    }
221
222    fn create_other_repository(local: &str) -> Result<(), Box<dyn Error>> {
223        let remote = format!("{local}-remote");
224        let other = format!("{local}-other");
225
226        // Create another directory to push the changes
227        cmd!("git", "clone", &remote, &other).read()?;
228        create_commit(&other, "2", "2")?;
229        push_all(&other)?;
230
231        Ok(())
232    }
233
234    fn create_commit(path: &str, file: &str, contents: &str) -> Result<(), Box<dyn Error>> {
235        fs::write(format!("{path}/{file}"), contents)?;
236        cmd!("git", "add", "-A").dir(path).read()?;
237        cmd!("git", "commit", "-m1").dir(path).read()?;
238
239        Ok(())
240    }
241
242    fn push_all(path: &str) -> Result<(), Box<dyn Error>> {
243        cmd!("git", "push", "origin", "master").dir(path).read()?;
244        cmd!("git", "push", "--tags").dir(path).read()?;
245
246        Ok(())
247    }
248
249    fn create_tag(path: &str, tag: &str) -> Result<(), Box<dyn Error>> {
250        cmd!("git", "tag", tag).dir(path).read()?;
251        push_all(path)?;
252
253        Ok(())
254    }
255
256    fn get_tags(path: &str) -> Result<String, Box<dyn Error>> {
257        let tags = cmd!("git", "tag", "-l").dir(path).read()?;
258
259        Ok(tags)
260    }
261
262    fn get_last_commit(path: &str) -> Result<String, Box<dyn Error>> {
263        let commit_sha = cmd!("git", "rev-parse", "HEAD").dir(path).read()?;
264
265        Ok(commit_sha)
266    }
267
268    fn cleanup_repository(local: &str) -> Result<(), Box<dyn Error>> {
269        let remote = format!("{local}-remote");
270        let other = format!("{local}-other");
271
272        fs::remove_dir_all(local)?;
273        if Path::new(&remote).exists() {
274            fs::remove_dir_all(remote)?;
275        }
276        if Path::new(&other).exists() {
277            fs::remove_dir_all(other)?;
278        }
279
280        Ok(())
281    }
282
283    fn create_failing_repository(local: &str, creating_commit: bool) -> Result<(), Box<dyn Error>> {
284        fs::create_dir(local)?;
285        cmd!("git", "init").dir(local).read()?;
286
287        if creating_commit {
288            create_commit(local, "1", "1")?;
289        }
290
291        Ok(())
292    }
293
294    fn create_merge_conflict(local: &str) -> Result<(), Box<dyn Error>> {
295        let other = format!("{local}-other");
296
297        create_commit(local, "1", "11")?;
298
299        create_commit(&other, "1", "21")?;
300
301        Ok(())
302    }
303
304    #[test]
305    fn it_should_open_a_repository() -> Result<(), Box<dyn Error>> {
306        let id = get_random_id();
307        let local = format!("test_directories/{id}");
308
309        create_empty_repository(&local)?;
310
311        let _ = GitCheck::open_inner(&local, GitTriggerArgument::Push)?;
312
313        let _ = cleanup_repository(&local);
314
315        Ok(())
316    }
317
318    #[test]
319    fn it_should_fail_if_path_is_invalid() -> Result<(), Box<dyn Error>> {
320        let error = GitCheck::open_inner("/path/to/nowhere", GitTriggerArgument::Push)
321            .err()
322            .unwrap();
323
324        assert!(
325            matches!(error, CheckError::Misconfigured(_)),
326            "{error:?} should be Misconfigured"
327        );
328
329        Ok(())
330    }
331
332    #[test]
333    fn it_should_fail_if_we_are_not_on_a_branch() -> Result<(), Box<dyn Error>> {
334        let id = get_random_id();
335        let local = format!("test_directories/{id}");
336
337        // Don't create commit to create an empty repository
338        create_failing_repository(&local, false)?;
339
340        let failing_check = GitCheck::open_inner(&local, GitTriggerArgument::Push);
341        let error = failing_check.err().unwrap();
342
343        assert!(
344            matches!(error, CheckError::Misconfigured(_)),
345            "{error:?} should be Misconfigured"
346        );
347
348        let _ = cleanup_repository(&local);
349
350        Ok(())
351    }
352
353    #[test]
354    fn it_should_fail_if_there_is_no_remote() -> Result<(), Box<dyn Error>> {
355        let id = get_random_id();
356        let local = format!("test_directories/{id}");
357
358        // Don't create commit to create an empty repository
359        create_failing_repository(&local, true)?;
360
361        let failing_check = GitCheck::open_inner(&local, GitTriggerArgument::Push);
362        let error = failing_check.err().unwrap();
363
364        assert!(
365            matches!(error, CheckError::Misconfigured(_)),
366            "{error:?} should be Misconfigured"
367        );
368
369        let _ = cleanup_repository(&local);
370
371        Ok(())
372    }
373
374    #[test]
375    fn it_should_return_false_if_the_remote_didnt_change() -> Result<(), Box<dyn Error>> {
376        let id = get_random_id();
377        let local = format!("test_directories/{id}");
378
379        create_empty_repository(&local)?;
380
381        let mut check = GitCheck::open_inner(&local, GitTriggerArgument::Push)?;
382        let mut context: Context = HashMap::new();
383        let is_pulled = check.check_inner(&mut context)?;
384        assert!(!is_pulled);
385
386        let _ = cleanup_repository(&local);
387
388        Ok(())
389    }
390
391    #[test]
392    fn it_should_return_true_if_the_remote_changes() -> Result<(), Box<dyn Error>> {
393        let id = get_random_id();
394        let local = format!("test_directories/{id}");
395
396        create_empty_repository(&local)?;
397
398        // Create another repository and push a new commit
399        create_other_repository(&local)?;
400
401        let before_commit_sha = get_last_commit(&local)?;
402        let mut check = GitCheck::open_inner(&local, GitTriggerArgument::Push)?;
403        let mut context: Context = HashMap::new();
404        let is_pulled = check.check_inner(&mut context)?;
405        assert!(is_pulled);
406
407        // The pushed file should be pulled
408        assert!(Path::new(&format!("{local}/2")).exists());
409
410        // It should set the context keys
411        let remote_path = fs::canonicalize(format!("{local}-remote"))?;
412        let remote = remote_path.to_str().unwrap();
413        let commit_sha = get_last_commit(&local)?;
414        assert_eq!("branch", context.get("GIT_REF_TYPE").unwrap());
415        assert_eq!("refs/heads/master", context.get("GIT_REF_NAME").unwrap());
416        assert_eq!("master", context.get("GIT_BRANCH_NAME").unwrap());
417        assert_eq!(
418            &before_commit_sha,
419            context.get("GIT_BEFORE_COMMIT_SHA").unwrap()
420        );
421        assert_eq!(
422            &before_commit_sha[0..7],
423            context.get("GIT_BEFORE_COMMIT_SHORT_SHA").unwrap()
424        );
425        assert_eq!(&commit_sha, context.get("GIT_COMMIT_SHA").unwrap());
426        assert_eq!(
427            &commit_sha[0..7],
428            context.get("GIT_COMMIT_SHORT_SHA").unwrap()
429        );
430        assert_eq!("origin", context.get("GIT_REMOTE_NAME").unwrap());
431        assert_eq!(
432            remote,
433            fs::canonicalize(context.get("GIT_REMOTE_URL").unwrap())?
434                .to_str()
435                .unwrap()
436        );
437
438        let _ = cleanup_repository(&local);
439
440        Ok(())
441    }
442
443    #[test]
444    fn it_should_return_true_if_the_remote_changes_with_tags() -> Result<(), Box<dyn Error>> {
445        let id = get_random_id();
446        let local = format!("test_directories/{id}");
447
448        create_empty_repository(&local)?;
449
450        // Create another repository, push a new commit and add a tag
451        create_other_repository(&local)?;
452
453        let other = format!("{local}-other");
454        create_tag(&other, "v0.1.0")?;
455        create_commit(&other, "3", "3")?;
456        push_all(&other)?;
457
458        let before_commit_sha = get_last_commit(&local)?;
459        let mut check = GitCheck::open_inner(&local, GitTriggerArgument::Tag("v*".to_string()))?;
460        let mut context: Context = HashMap::new();
461        let is_pulled = check.check_inner(&mut context)?;
462        assert!(is_pulled);
463
464        // The pushed file should be pulled
465        assert!(Path::new(&format!("{local}/2")).exists());
466
467        // The last commit should not be checked out
468        assert!(!Path::new(&format!("{local}/3")).exists());
469
470        // Tag should be downloaded
471        let tags = get_tags(&local)?;
472        assert_eq!(tags, "v0.1.0");
473
474        // It should set the context keys
475        let remote_path = fs::canonicalize(format!("{local}-remote"))?;
476        let remote = remote_path.to_str().unwrap();
477        let commit_sha = get_last_commit(&local)?;
478        assert_eq!("tag", context.get("GIT_REF_TYPE").unwrap());
479        assert_eq!("refs/tags/v0.1.0", context.get("GIT_REF_NAME").unwrap());
480        assert_eq!("master", context.get("GIT_BRANCH_NAME").unwrap());
481        assert_eq!(
482            &before_commit_sha,
483            context.get("GIT_BEFORE_COMMIT_SHA").unwrap()
484        );
485        assert_eq!(
486            &before_commit_sha[0..7],
487            context.get("GIT_BEFORE_COMMIT_SHORT_SHA").unwrap()
488        );
489        assert_eq!(&commit_sha, context.get("GIT_COMMIT_SHA").unwrap());
490        assert_eq!(
491            &commit_sha[0..7],
492            context.get("GIT_COMMIT_SHORT_SHA").unwrap()
493        );
494        assert_eq!("origin", context.get("GIT_REMOTE_NAME").unwrap());
495        assert_eq!(
496            remote,
497            fs::canonicalize(context.get("GIT_REMOTE_URL").unwrap())?
498                .to_str()
499                .unwrap()
500        );
501
502        let _ = cleanup_repository(&local);
503
504        Ok(())
505    }
506
507    #[test]
508    fn it_should_return_false_if_no_new_tag_with_tags() -> Result<(), Box<dyn Error>> {
509        let id = get_random_id();
510        let local = format!("test_directories/{id}");
511
512        // Create tag with current repository to test if that triggers
513        create_empty_repository(&local)?;
514        create_tag(&local, "v0.2.0")?;
515        push_all(&local)?;
516
517        // Create another repository, push a new commit and add a tag
518        create_other_repository(&local)?;
519
520        let other = format!("{local}-other");
521        create_commit(&other, "3", "3")?;
522        push_all(&other)?;
523
524        let mut check = GitCheck::open_inner(&local, GitTriggerArgument::Tag("v*".to_string()))?;
525        let mut context: Context = HashMap::new();
526        let is_pulled = check.check_inner(&mut context)?;
527        assert!(!is_pulled);
528
529        // The commits should not be downloaded
530        assert!(!Path::new(&format!("{local}/2")).exists());
531        assert!(!Path::new(&format!("{local}/3")).exists());
532
533        let _ = cleanup_repository(&local);
534
535        Ok(())
536    }
537
538    #[test]
539    fn it_should_return_false_if_no_tag_matches_with_tags() -> Result<(), Box<dyn Error>> {
540        let id = get_random_id();
541        let local = format!("test_directories/{id}");
542
543        create_empty_repository(&local)?;
544
545        // Create another repository, push a new commit and add a tag
546        create_other_repository(&local)?;
547
548        let other = format!("{local}-other");
549        create_tag(&other, "v0.1.0")?;
550        create_commit(&other, "3", "3")?;
551        push_all(&other)?;
552
553        let mut check =
554            GitCheck::open_inner(&local, GitTriggerArgument::Tag("no-match".to_string()))?;
555        let mut context: Context = HashMap::new();
556        let is_pulled = check.check_inner(&mut context)?;
557        assert!(!is_pulled);
558
559        // The commits should not be downloaded
560        assert!(!Path::new(&format!("{local}/2")).exists());
561        assert!(!Path::new(&format!("{local}/3")).exists());
562
563        let _ = cleanup_repository(&local);
564
565        Ok(())
566    }
567
568    #[test]
569    fn it_should_fail_if_the_working_tree_is_dirty() -> Result<(), Box<dyn Error>> {
570        let id = get_random_id();
571        let local = format!("test_directories/{id}");
572
573        create_empty_repository(&local)?;
574
575        // Create another repository and push a new commit
576        create_other_repository(&local)?;
577
578        // Add uncommited modification to emulate a dirty working tree
579        fs::write(format!("{local}/1"), "22")?;
580
581        let mut check = GitCheck::open_inner(&local, GitTriggerArgument::Push)?;
582        let mut context: Context = HashMap::new();
583        let error = check.check_inner(&mut context).err().unwrap();
584
585        assert!(
586            matches!(error, GitError::DirtyWorkingTree),
587            "{error:?} should be DirtyWorkingTree"
588        );
589
590        // The pushed file should be pulled
591        assert!(!Path::new(&format!("{local}/2")).exists());
592
593        let _ = cleanup_repository(&local);
594
595        Ok(())
596    }
597
598    #[test]
599    fn it_should_fail_if_there_is_a_merge_conflict() -> Result<(), Box<dyn Error>> {
600        let id = get_random_id();
601        let local = format!("test_directories/{id}");
602
603        create_empty_repository(&local)?;
604
605        // Create another repository and push a new commit
606        create_other_repository(&local)?;
607
608        // Modify the same file in both directories to create a merge conflict
609        create_merge_conflict(&local)?;
610
611        let mut check = GitCheck::open_inner(&local, GitTriggerArgument::Push)?;
612        let mut context: Context = HashMap::new();
613        let error = check.check_inner(&mut context).err().unwrap();
614
615        assert!(
616            matches!(error, GitError::MergeConflict),
617            "{error:?} should be MergeConflict"
618        );
619
620        let _ = cleanup_repository(&local);
621
622        Ok(())
623    }
624
625    #[test]
626    #[cfg(unix)]
627    fn it_should_fail_if_repository_is_not_accessible() -> Result<(), Box<dyn Error>> {
628        let id = get_random_id();
629        let local = format!("test_directories/{id}");
630
631        create_empty_repository(&local)?;
632
633        // Create another repository and push a new commit
634        create_other_repository(&local)?;
635
636        // Set repository to readonly
637        let mut perms = fs::metadata(&local)?.permissions();
638        perms.set_readonly(true);
639        fs::set_permissions(&local, perms)?;
640
641        let mut check: GitCheck = GitCheck::open_inner(&local, GitTriggerArgument::Push)?;
642        let mut context: Context = HashMap::new();
643        let error = check.check_inner(&mut context).err().unwrap();
644
645        assert!(
646            matches!(error, GitError::FailedSettingHead(_)),
647            "{error:?} should be FailedSettingHead"
648        );
649
650        // Set repository to readonly
651        let mut perms = fs::metadata(&local)?.permissions();
652        #[allow(clippy::permissions_set_readonly_false)]
653        perms.set_readonly(false);
654        fs::set_permissions(&local, perms)?;
655
656        let _ = cleanup_repository(&local);
657
658        Ok(())
659    }
660}