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
41pub struct GitCheck {
46 pub repo: GitRepository,
47 pub trigger: GitTriggerArgument,
48}
49
50#[derive(Debug, Error)]
52pub enum GitError {
53 #[error("{0} is not a valid git repository ({1})")]
55 NotAGitRepository(String, String),
56 #[error("HEAD is invalid, probably points to invalid commit")]
58 NoHead,
59 #[error("repository is not on a branch, checkout or create a commit first")]
62 NotOnABranch,
63 #[error("branch {0} doesn't have a remote, push your commits first")]
65 NoRemoteForBranch(String),
66 #[error("there are uncommited changes in the directory")]
69 DirtyWorkingTree,
70 #[error("cannot load git config")]
72 ConfigLoadingFailed,
73 #[error("cannot create ssh config")]
75 SshConfigFailed,
76 #[error("cannot fetch ({0})")]
78 FetchFailed(String),
79 #[error("cannot update branch, this is likely a merge conflict")]
82 MergeConflict,
83 #[error("failed matching tags between the new commit and the branch")]
85 TagMatchingFailed,
86 #[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 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 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 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 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 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 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 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 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_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 assert!(Path::new(&format!("{local}/2")).exists());
409
410 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_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 assert!(Path::new(&format!("{local}/2")).exists());
466
467 assert!(!Path::new(&format!("{local}/3")).exists());
469
470 let tags = get_tags(&local)?;
472 assert_eq!(tags, "v0.1.0");
473
474 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_empty_repository(&local)?;
514 create_tag(&local, "v0.2.0")?;
515 push_all(&local)?;
516
517 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 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_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 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_other_repository(&local)?;
577
578 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 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_other_repository(&local)?;
607
608 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_other_repository(&local)?;
635
636 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 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}