1use std::{
13 fs,
14 path::{Path, PathBuf},
15 sync::atomic::AtomicBool,
16};
17
18use backon::{BlockingRetryable, ExponentialBuilder};
19use gix::{ObjectId, bstr::BString, protocol::transport::IsSpuriousError, remote::Direction};
20use serde::{Deserialize, Serialize};
21use snafu::{IntoError, ResultExt, prelude::*};
22
23use crate::{
24 cache::Cache,
25 config::HttpConfig,
26 messages::{GitMessage, MessageReporter},
27};
28
29#[derive(Debug, Snafu)]
31#[snafu(visibility(pub(crate)))]
32pub(crate) enum Error {
33 #[snafu(display("Git commit hash is invalid: {hash}"))]
34 InvalidCommitHash {
35 hash: String,
36 #[snafu(source(from(gix::hash::decode::Error, Box::new)))]
37 source: Box<gix::hash::decode::Error>,
38 },
39
40 #[snafu(display("Failed to initialize bare repository at {}", path.display()))]
41 InitBareRepo {
42 path: PathBuf,
43 #[snafu(source(from(gix::init::Error, Box::new)))]
44 source: Box<gix::init::Error>,
45 },
46
47 #[snafu(display("Failed to open git repository at {}", path.display()))]
48 OpenRepo {
49 path: PathBuf,
50 #[snafu(source(from(gix::open::Error, Box::new)))]
51 source: Box<gix::open::Error>,
52 },
53
54 #[snafu(display("Failed to resolve git selector: {message}"))]
55 ResolveSelector {
56 message: String,
57 source: Box<dyn std::error::Error + Send + Sync>,
58 },
59
60 #[snafu(display("Failed to fetch ref from '{url}'"))]
61 FetchRef {
62 url: String,
63 source: Box<dyn std::error::Error + Send + Sync>,
64 },
65
66 #[snafu(display("Failed to checkout from database to {}", path.display()))]
67 CheckoutFromDb {
68 path: PathBuf,
69 source: Box<dyn std::error::Error + Send + Sync>,
70 },
71
72 #[snafu(display("Failed to create git directory at {}", path.display()))]
73 CreateDirectory { path: PathBuf, source: std::io::Error },
74
75 #[snafu(display("Failed to write marker file at {}", path.display()))]
76 WriteMarkerFile { path: PathBuf, source: std::io::Error },
77}
78
79pub(crate) type Result<T> = std::result::Result<T, Error>;
80
81#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub enum GitSelector {
87 #[default]
89 DefaultBranch,
90 Branch(String),
92 Tag(String),
94 Commit(String),
96}
97
98#[derive(Clone, Debug)]
107pub(crate) struct GitClient {
108 cache: Cache,
109 reporter: MessageReporter,
110 http_config: HttpConfig,
111}
112
113impl GitClient {
114 pub(crate) fn new(cache: Cache, reporter: MessageReporter, http_config: HttpConfig) -> Self {
116 Self {
117 cache,
118 reporter,
119 http_config,
120 }
121 }
122
123 pub(crate) fn checkout_ref(&self, url: &str, selector: GitSelector) -> Result<(PathBuf, String)> {
133 let db_path = self.ensure_db(url)?;
134
135 self.reporter.report(|| GitMessage::resolving_ref(url, &selector));
137
138 let commit_str = if let Ok(oid) = resolve_selector(&db_path, &selector) {
139 let commit_str = oid.to_string();
141 self.reporter
142 .report(|| GitMessage::ref_found_locally(url, &selector, &commit_str));
143 commit_str
144 } else {
145 self.reporter.report(|| GitMessage::fetching_repo(url, &selector));
147 fetch_ref(&db_path, url, &selector, &self.http_config)?;
148 let oid = resolve_selector(&db_path, &selector)?;
149 let commit_str = oid.to_string();
150 self.reporter.report(|| GitMessage::resolved_ref(&commit_str));
151 commit_str
152 };
153
154 let checkout_path = self.ensure_checkout(&db_path, url, &commit_str)?;
155 Ok((checkout_path, commit_str))
156 }
157
158 fn ensure_db(&self, url: &str) -> Result<PathBuf> {
159 let db_path = self.cache.git_db_path(url);
160
161 if !db_path.exists() {
162 fs::create_dir_all(&db_path).with_context(|_| CreateDirectorySnafu {
163 path: db_path.clone(),
164 })?;
165 init_bare_repo(&db_path)?;
166 }
167
168 Ok(db_path)
169 }
170
171 fn ensure_checkout(&self, db_path: &Path, url: &str, commit: &str) -> Result<PathBuf> {
172 let checkout_path = self.cache.git_checkout_path(url, commit);
173
174 if checkout_path.exists() && checkout_path.join(".cgx-ok").exists() {
176 self.reporter
177 .report(|| GitMessage::checkout_exists(commit, &checkout_path));
178 return Ok(checkout_path);
179 }
180
181 self.reporter
183 .report(|| GitMessage::checking_out(commit, &checkout_path));
184
185 fs::create_dir_all(&checkout_path).with_context(|_| CreateDirectorySnafu {
186 path: checkout_path.clone(),
187 })?;
188 let _ = fs::remove_file(checkout_path.join(".cgx-ok"));
189
190 let commit_oid = ObjectId::from_hex(commit.as_bytes())
191 .map_err(|e| InvalidCommitHashSnafu { hash: commit }.into_error(e))?;
192
193 checkout_from_db(db_path, commit_oid, &checkout_path)?;
194
195 let marker_path = checkout_path.join(".cgx-ok");
197 fs::write(&marker_path, "").with_context(|_| WriteMarkerFileSnafu {
198 path: marker_path.clone(),
199 })?;
200
201 self.reporter
203 .report(|| GitMessage::checkout_complete(&checkout_path));
204
205 Ok(checkout_path)
206 }
207}
208
209fn init_bare_repo(path: &Path) -> Result<()> {
212 gix::init_bare(path)
213 .map_err(|e| {
214 InitBareRepoSnafu {
215 path: path.to_path_buf(),
216 }
217 .into_error(e)
218 })
219 .map(|_| ())
220}
221
222fn fetch_ref(db_path: &Path, url: &str, selector: &GitSelector, http_config: &HttpConfig) -> Result<()> {
223 let backoff = ExponentialBuilder::default()
224 .with_min_delay(http_config.backoff_base)
225 .with_max_delay(http_config.backoff_max)
226 .with_max_times(http_config.retries)
227 .with_jitter();
228
229 (|| fetch_ref_impl(db_path, url, selector, http_config))
230 .retry(backoff)
231 .when(is_retryable_error)
232 .sleep(std::thread::sleep)
233 .call()
234}
235
236fn is_retryable_error(e: &Error) -> bool {
250 let Error::FetchRef { source, .. } = e else {
251 return false;
252 };
253 let err = source.as_ref();
254
255 let spurious = if let Some(e) = err.downcast_ref::<gix::remote::connect::Error>() {
256 e.is_spurious()
257 } else if let Some(e) = err.downcast_ref::<gix::remote::fetch::prepare::Error>() {
258 e.is_spurious()
259 } else if let Some(e) = err.downcast_ref::<gix::remote::fetch::Error>() {
260 e.is_spurious()
261 } else {
262 false
263 };
264
265 if spurious {
266 return true;
267 }
268
269 let mut source: Option<&(dyn std::error::Error)> = Some(err);
271 while let Some(current) = source {
272 if let Some(io_err) = current.downcast_ref::<std::io::Error>() {
273 if io_err.to_string().contains("Received HTTP status 429") {
274 return true;
275 }
276 }
277 source = current.source();
278 }
279
280 false
281}
282
283fn http_config_overrides(http_config: &HttpConfig) -> Vec<BString> {
284 let ua = crate::http::user_agent();
285
286 let low_speed_time_secs = http_config.timeout.as_secs().max(1);
291
292 let mut overrides = vec![
293 format!("gitoxide.userAgent={ua}").into(),
297 format!("http.userAgent={ua}").into(),
301 format!("http.extraHeader=User-Agent: {ua}").into(),
305 format!("gitoxide.http.connectTimeout={}", http_config.timeout.as_millis()).into(),
306 "http.lowSpeedLimit=1".into(),
307 format!("http.lowSpeedTime={low_speed_time_secs}").into(),
308 ];
309
310 if let Some(ref proxy) = http_config.proxy {
311 overrides.push(format!("http.proxy={proxy}").into());
312 }
313
314 overrides
315}
316
317fn http_open_options(http_config: &HttpConfig) -> gix::open::Options {
318 let overrides = http_config_overrides(http_config);
319 gix::open::Options::default().config_overrides(overrides)
320}
321
322fn fetch_ref_impl(db_path: &Path, url: &str, selector: &GitSelector, http_config: &HttpConfig) -> Result<()> {
323 let repo = gix::open_opts(db_path, http_open_options(http_config)).map_err(|e| {
324 OpenRepoSnafu {
325 path: db_path.to_path_buf(),
326 }
327 .into_error(e)
328 })?;
329
330 let refspec = match selector {
332 GitSelector::DefaultBranch => "+HEAD:refs/remotes/origin/HEAD".to_string(),
333 GitSelector::Branch(b) => format!("+refs/heads/{b}:refs/remotes/origin/{b}"),
334 GitSelector::Tag(t) => format!("+refs/tags/{t}:refs/remotes/origin/tags/{t}"),
335 GitSelector::Commit(c) if c.len() == 40 => {
336 format!("+{c}:refs/commit/{c}")
344 }
345 GitSelector::Commit(_) => {
346 "+HEAD:refs/remotes/origin/HEAD".to_string()
349 }
350 };
351
352 let remote = repo
354 .remote_at(url)
355 .map_err(|e| FetchRefSnafu { url: url.to_string() }.into_error(Box::new(e)))?
356 .with_refspecs([refspec.as_str()], Direction::Fetch)
357 .map_err(|e| FetchRefSnafu { url: url.to_string() }.into_error(Box::new(e)))?;
358
359 let connection = remote
360 .connect(Direction::Fetch)
361 .map_err(|e| FetchRefSnafu { url: url.to_string() }.into_error(Box::new(e)))?;
362
363 connection
364 .prepare_fetch(&mut gix::progress::Discard, Default::default())
365 .map_err(|e| FetchRefSnafu { url: url.to_string() }.into_error(Box::new(e)))?
366 .receive(&mut gix::progress::Discard, &AtomicBool::new(false))
367 .map_err(|e| FetchRefSnafu { url: url.to_string() }.into_error(Box::new(e)))?;
368
369 Ok(())
370}
371
372fn resolve_selector(db_path: &Path, selector: &GitSelector) -> Result<ObjectId> {
373 let repo = gix::open(db_path).map_err(|e| {
374 OpenRepoSnafu {
375 path: db_path.to_path_buf(),
376 }
377 .into_error(e)
378 })?;
379
380 let oid = match selector {
381 GitSelector::DefaultBranch => {
382 let ref_name = "refs/remotes/origin/HEAD";
383 let reference = repo.find_reference(ref_name).map_err(|e| {
384 ResolveSelectorSnafu {
385 message: format!("Failed to find {}", ref_name),
386 }
387 .into_error(Box::new(e))
388 })?;
389 reference
390 .into_fully_peeled_id()
391 .map_err(|e| {
392 ResolveSelectorSnafu {
393 message: "Failed to peel reference".to_string(),
394 }
395 .into_error(Box::new(e))
396 })?
397 .detach()
398 }
399 GitSelector::Branch(b) => {
400 let ref_name = format!("refs/remotes/origin/{}", b);
401 let reference = repo.find_reference(&ref_name).map_err(|e| {
402 ResolveSelectorSnafu {
403 message: format!("Branch '{}' not found", b),
404 }
405 .into_error(Box::new(e))
406 })?;
407 reference
408 .into_fully_peeled_id()
409 .map_err(|e| {
410 ResolveSelectorSnafu {
411 message: format!("Failed to peel branch '{}'", b),
412 }
413 .into_error(Box::new(e))
414 })?
415 .detach()
416 }
417 GitSelector::Tag(t) => {
418 let ref_name = format!("refs/remotes/origin/tags/{}", t);
419 let reference = repo.find_reference(&ref_name).map_err(|e| {
420 ResolveSelectorSnafu {
421 message: format!("Tag '{}' not found", t),
422 }
423 .into_error(Box::new(e))
424 })?;
425 reference
427 .into_fully_peeled_id()
428 .map_err(|e| {
429 ResolveSelectorSnafu {
430 message: format!("Failed to peel tag '{}'", t),
431 }
432 .into_error(Box::new(e))
433 })?
434 .detach()
435 }
436 GitSelector::Commit(c) => {
437 let spec = repo.rev_parse_single(c.as_bytes()).map_err(|e| {
439 ResolveSelectorSnafu {
440 message: format!("Failed to resolve commit '{}'", c),
441 }
442 .into_error(Box::new(e))
443 })?;
444 spec.object()
445 .map_err(|e| {
446 ResolveSelectorSnafu {
447 message: format!("Failed to get object for commit '{}'", c),
448 }
449 .into_error(Box::new(e))
450 })?
451 .id
452 }
453 };
454
455 Ok(oid)
456}
457
458fn checkout_from_db(db_path: &Path, commit_oid: ObjectId, dest: &Path) -> Result<()> {
459 let repo = gix::open(db_path).map_err(|e| {
460 OpenRepoSnafu {
461 path: db_path.to_path_buf(),
462 }
463 .into_error(e)
464 })?;
465
466 let commit = repo.find_commit(commit_oid).map_err(|e| {
468 CheckoutFromDbSnafu {
469 path: dest.to_path_buf(),
470 }
471 .into_error(Box::new(e))
472 })?;
473
474 let tree_id = commit.tree_id().map_err(|e| {
475 CheckoutFromDbSnafu {
476 path: dest.to_path_buf(),
477 }
478 .into_error(Box::new(e))
479 })?;
480
481 let mut index = repo.index_from_tree(&tree_id).map_err(|e| {
483 CheckoutFromDbSnafu {
484 path: dest.to_path_buf(),
485 }
486 .into_error(Box::new(e))
487 })?;
488
489 let options = repo
491 .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)
492 .map_err(|e| {
493 CheckoutFromDbSnafu {
494 path: dest.to_path_buf(),
495 }
496 .into_error(Box::new(e))
497 })?;
498
499 gix::worktree::state::checkout(
501 &mut index,
502 dest,
503 repo.objects.clone(),
504 &gix::progress::Discard,
505 &gix::progress::Discard,
506 &AtomicBool::new(false),
507 options,
508 )
509 .map_err(|e| {
510 CheckoutFromDbSnafu {
511 path: dest.to_path_buf(),
512 }
513 .into_error(Box::new(e))
514 })?;
515
516 Ok(())
517}
518
519#[cfg(test)]
520mod tests {
521 use assert_matches::assert_matches;
522 use tempfile::TempDir;
523
524 use super::*;
525
526 fn test_git_client() -> (GitClient, TempDir) {
527 let (temp_dir, config) = crate::config::create_test_env();
528 let reporter = MessageReporter::null();
529 let cache = Cache::new(config.clone(), reporter.clone());
530 let git_client = GitClient::new(cache, reporter, config.http);
531 (git_client, temp_dir)
532 }
533
534 mod http_config_overrides {
535 use super::*;
536
537 fn overrides_to_strings(overrides: Vec<BString>) -> Vec<String> {
538 overrides
539 .into_iter()
540 .map(|override_value| String::from_utf8_lossy(override_value.as_ref()).into_owned())
541 .collect()
542 }
543
544 #[test]
545 fn includes_user_agent_and_timeout_settings() {
546 let (_temp_dir, config) = crate::config::create_test_env();
547 let overrides = overrides_to_strings(http_config_overrides(&config.http));
548
549 assert!(overrides.iter().any(|o| o.starts_with("gitoxide.userAgent=")));
550 assert!(overrides.iter().any(|o| o.starts_with("http.userAgent=")));
551 assert!(
552 overrides
553 .iter()
554 .any(|o| o.starts_with("gitoxide.http.connectTimeout="))
555 );
556 assert!(overrides.iter().any(|o| o == "http.lowSpeedLimit=1"));
557 }
558
559 #[test]
560 fn includes_proxy_when_configured() {
561 let (_temp_dir, mut config) = crate::config::create_test_env();
562 config.http.proxy = Some("http://proxy.example:8080".to_string());
563 let overrides = overrides_to_strings(http_config_overrides(&config.http));
564
565 assert!(
566 overrides
567 .iter()
568 .any(|o| o == "http.proxy=http://proxy.example:8080")
569 );
570 }
571
572 #[test]
573 fn omits_proxy_when_not_configured() {
574 let (_temp_dir, config) = crate::config::create_test_env();
575 let overrides = overrides_to_strings(http_config_overrides(&config.http));
576
577 assert!(!overrides.iter().any(|o| o.starts_with("http.proxy=")));
578 }
579 }
580
581 mod checkout_ref {
582 use super::*;
583
584 #[test]
585 fn checkout_default_branch() {
586 let (git_client, _temp) = test_git_client();
587 let url = "https://github.com/rust-lang/rustlings.git";
588
589 let (checkout_path, _commit_hash) =
590 git_client.checkout_ref(url, GitSelector::DefaultBranch).unwrap();
591 assert!(checkout_path.exists());
592 assert!(checkout_path.join(".cgx-ok").exists());
593 }
594
595 #[test]
596 fn checkout_specific_branch() {
597 let (git_client, _temp) = test_git_client();
598 let url = "https://github.com/rust-lang/rustlings.git";
599
600 let (checkout_path, _commit_hash) = git_client
601 .checkout_ref(url, GitSelector::Branch("main".to_string()))
602 .unwrap();
603 assert!(checkout_path.exists());
604 assert!(checkout_path.join("Cargo.toml").exists());
605 }
606
607 #[test]
608 fn checkout_specific_tag() {
609 let (git_client, _temp) = test_git_client();
610 let url = "https://github.com/rust-lang/rustlings.git";
611
612 let (checkout_path, commit_hash) = git_client
613 .checkout_ref(url, GitSelector::Tag("v6.0.0".to_string()))
614 .unwrap();
615 assert!(checkout_path.exists());
616
617 assert_eq!("28d2bb04326d7036514245d73f10fb72b9ed108c", &commit_hash);
619 }
620
621 #[test]
624 fn checkout_specific_advertised_commit() {
625 let (git_client, _temp) = test_git_client();
626 let url = "https://github.com/rust-lang/rustlings.git";
627
628 let commit = "28d2bb04326d7036514245d73f10fb72b9ed108c";
630
631 let (checkout_path, commit_hash) = git_client
632 .checkout_ref(url, GitSelector::Commit(commit.to_string()))
633 .unwrap();
634 assert!(checkout_path.exists());
635 assert!(checkout_path.join(".cgx-ok").exists());
636 assert_eq!(commit, &commit_hash);
637
638 drop(_temp);
641 let (git_client, _temp) = test_git_client();
642 #[expect(
643 clippy::string_slice,
644 reason = "commit is a 40-char ASCII hex literal in tests, so [..7] is in range and on a \
645 char boundary"
646 )]
647 let short_commit = &commit[..7];
648 let (checkout_path, commit_hash) = git_client
649 .checkout_ref(url, GitSelector::Commit(short_commit.to_string()))
650 .unwrap();
651 assert!(checkout_path.exists());
652 assert!(checkout_path.join(".cgx-ok").exists());
653 assert_eq!(commit, &commit_hash);
654 }
655
656 #[test]
660 fn checkout_specific_non_advertised_commit() {
661 let (git_client, _temp) = test_git_client();
662 let url = "https://github.com/rust-lang/rustlings.git";
663
664 let commit = "6cf75d569bd0dd33a041e37c59cb75d28664bd7b";
666
667 let (checkout_path, commit_hash) = git_client
668 .checkout_ref(url, GitSelector::Commit(commit.to_string()))
669 .unwrap();
670 assert!(checkout_path.exists());
671 assert!(checkout_path.join(".cgx-ok").exists());
672 assert_eq!(commit, &commit_hash);
673
674 drop(_temp);
677 let (git_client, _temp) = test_git_client();
678 #[expect(
679 clippy::string_slice,
680 reason = "commit is a 40-char ASCII hex literal in tests, so [..7] is in range and on a \
681 char boundary"
682 )]
683 let short_commit = &commit[..7];
684 let (checkout_path, commit_hash) = git_client
685 .checkout_ref(url, GitSelector::Commit(short_commit.to_string()))
686 .unwrap();
687 assert!(checkout_path.exists());
688 assert!(checkout_path.join(".cgx-ok").exists());
689 assert_eq!(commit, &commit_hash);
690 }
691
692 #[test]
693 fn cache_reuse_same_commit() {
694 let (git_client, _temp) = test_git_client();
695 let url = "https://github.com/rust-lang/rustlings.git";
696 let commit = "28d2bb04326d7036514245d73f10fb72b9ed108c";
697
698 let (first_checkout_path, first_checkout_hash) = git_client
700 .checkout_ref(url, GitSelector::Commit(commit.to_string()))
701 .unwrap();
702
703 let (second_checkout_path, second_checkout_hash) = git_client
705 .checkout_ref(url, GitSelector::Commit(commit.to_string()))
706 .unwrap();
707
708 assert_eq!(commit, &first_checkout_hash);
709 assert_eq!(commit, &second_checkout_hash);
710
711 assert_eq!(first_checkout_path, second_checkout_path);
712 }
713
714 #[test]
715 fn nonexistent_branch() {
716 let (git_client, _temp) = test_git_client();
717 let url = "https://github.com/rust-lang/rustlings.git";
718
719 let result = git_client.checkout_ref(
720 url,
721 GitSelector::Branch("this-branch-does-not-exist-xyzzy".to_string()),
722 );
723 assert_matches!(result, Err(Error::FetchRef { .. }));
724 }
725
726 #[test]
727 fn nonexistent_tag() {
728 let (git_client, _temp) = test_git_client();
729 let url = "https://github.com/rust-lang/rustlings.git";
730
731 let result = git_client.checkout_ref(url, GitSelector::Tag("v999.999.999".to_string()));
732 assert_matches!(result, Err(Error::FetchRef { .. }));
733 }
734
735 #[test]
736 fn nonexistent_commit() {
737 let (git_client, _temp) = test_git_client();
738 let url = "https://github.com/rust-lang/rustlings.git";
739
740 let result = git_client.checkout_ref(
741 url,
742 GitSelector::Commit("0000000000000000000000000000000000000000".to_string()),
743 );
744 assert_matches!(result, Err(Error::FetchRef { .. }));
745 }
746 }
747
748 mod integration {
754 use std::time::Duration;
755
756 use httpmock::prelude::*;
757
758 use super::*;
759
760 fn fast_retry_config() -> HttpConfig {
763 HttpConfig {
764 retries: 2,
765 backoff_base: Duration::from_millis(1),
766 backoff_max: Duration::from_millis(10),
767 timeout: Duration::from_secs(30),
768 ..Default::default()
769 }
770 }
771
772 fn no_retry_config() -> HttpConfig {
775 HttpConfig {
776 retries: 0,
777 backoff_base: Duration::from_millis(1),
778 backoff_max: Duration::from_millis(1),
779 timeout: Duration::from_secs(5),
780 ..Default::default()
781 }
782 }
783
784 fn test_bare_repo() -> (TempDir, PathBuf) {
787 let temp_dir = TempDir::new().unwrap();
788 let repo_path = temp_dir.path().join("bare.git");
789 fs::create_dir_all(&repo_path).unwrap();
790 init_bare_repo(&repo_path).unwrap();
791 (temp_dir, repo_path)
792 }
793
794 #[test]
795 fn server_503_is_retried() {
796 let server = MockServer::start();
797 let mock = server.mock(|_when, then| {
798 then.status(503);
799 });
800
801 let (_temp, db_path) = test_bare_repo();
802 let config = fast_retry_config();
803 let result = fetch_ref(
804 &db_path,
805 &server.url("/repo.git"),
806 &GitSelector::DefaultBranch,
807 &config,
808 );
809
810 assert_matches!(result, Err(Error::FetchRef { .. }));
811 mock.assert_calls(3);
812 }
813
814 #[test]
815 fn server_500_is_retried() {
816 let server = MockServer::start();
817 let mock = server.mock(|_when, then| {
818 then.status(500);
819 });
820
821 let (_temp, db_path) = test_bare_repo();
822 let config = fast_retry_config();
823 let result = fetch_ref(
824 &db_path,
825 &server.url("/repo.git"),
826 &GitSelector::DefaultBranch,
827 &config,
828 );
829
830 assert_matches!(result, Err(Error::FetchRef { .. }));
831 mock.assert_calls(3);
832 }
833
834 #[test]
835 fn server_429_is_retried() {
836 let server = MockServer::start();
837 let mock = server.mock(|_when, then| {
838 then.status(429);
839 });
840
841 let (_temp, db_path) = test_bare_repo();
842 let config = fast_retry_config();
843 let result = fetch_ref(
844 &db_path,
845 &server.url("/repo.git"),
846 &GitSelector::DefaultBranch,
847 &config,
848 );
849
850 assert_matches!(result, Err(Error::FetchRef { .. }));
851 mock.assert_calls(3);
852 }
853
854 #[test]
855 fn server_403_is_not_retried() {
856 let server = MockServer::start();
857 let mock = server.mock(|_when, then| {
858 then.status(403);
859 });
860
861 let (_temp, db_path) = test_bare_repo();
862 let config = fast_retry_config();
863 let result = fetch_ref(
864 &db_path,
865 &server.url("/repo.git"),
866 &GitSelector::DefaultBranch,
867 &config,
868 );
869
870 assert_matches!(result, Err(Error::FetchRef { .. }));
871 mock.assert_calls(1);
872 }
873
874 #[test]
875 fn server_404_is_not_retried() {
876 let server = MockServer::start();
877 let mock = server.mock(|_when, then| {
878 then.status(404);
879 });
880
881 let (_temp, db_path) = test_bare_repo();
882 let config = fast_retry_config();
883 let result = fetch_ref(
884 &db_path,
885 &server.url("/repo.git"),
886 &GitSelector::DefaultBranch,
887 &config,
888 );
889
890 assert_matches!(result, Err(Error::FetchRef { .. }));
891 mock.assert_calls(1);
892 }
893
894 #[test]
895 fn connection_timeout_is_retried() {
896 let server = MockServer::start();
897 let mock = server.mock(|_when, then| {
898 then.status(200).delay(Duration::from_secs(3));
899 });
900
901 let (_temp, db_path) = test_bare_repo();
902 let config = HttpConfig {
903 retries: 2,
904 backoff_base: Duration::from_millis(1),
905 backoff_max: Duration::from_millis(10),
906 timeout: Duration::from_secs(1),
907 ..Default::default()
908 };
909 let result = fetch_ref(
910 &db_path,
911 &server.url("/repo.git"),
912 &GitSelector::DefaultBranch,
913 &config,
914 );
915
916 assert_matches!(result, Err(Error::FetchRef { .. }));
917 mock.assert_calls(3);
918 }
919
920 #[test]
921 fn user_agent_is_applied_to_git_http_requests() {
922 let server = MockServer::start();
923 let expected_ua = crate::http::user_agent();
924 let mock = server.mock(|when, then| {
925 when.method(GET)
926 .path("/repo.git/info/refs")
927 .query_param("service", "git-upload-pack")
928 .header("User-Agent", expected_ua.as_str());
929 then.status(500);
930 });
931
932 let (_temp, db_path) = test_bare_repo();
933 let config = no_retry_config();
934 let result = fetch_ref(
935 &db_path,
936 &server.url("/repo.git"),
937 &GitSelector::DefaultBranch,
938 &config,
939 );
940
941 assert_matches!(result, Err(Error::FetchRef { .. }));
942 mock.assert_calls(1);
943 }
944
945 #[test]
946 fn proxy_setting_is_used_for_git_http_requests() {
947 let server = MockServer::start();
948 let expected_ua = crate::http::user_agent();
949 let mock = server.mock(|when, then| {
950 when.method(GET)
951 .host("example.invalid")
952 .path("/repo.git/info/refs")
953 .query_param("service", "git-upload-pack")
954 .header("User-Agent", expected_ua.as_str());
955 then.status(502);
956 });
957
958 let (_temp, db_path) = test_bare_repo();
959 let config = HttpConfig {
960 proxy: Some(server.base_url()),
961 ..no_retry_config()
962 };
963
964 let result = fetch_ref(
965 &db_path,
966 "http://example.invalid/repo.git",
967 &GitSelector::DefaultBranch,
968 &config,
969 );
970
971 assert_matches!(result, Err(Error::FetchRef { .. }));
972 mock.assert_calls(1);
973 }
974 }
975}