Skip to main content

cgx_core/
git.rs

1//! Git operations for cgx
2//!
3//! This module implements a two-tier git caching system inspired by cargo:
4//! 1. Git database cache (bare repositories) - one per URL
5//! 2. Git checkout cache (working trees) - one per commit
6//!
7//! This architecture enables:
8//! - Targeted refspec fetches which can be much more efficient for large repos
9//! - Warm cache reuse when multiple commits from the same repo are used over time
10//! - Correct handling of submodules, filters, and line endings via native gix checkout
11
12use 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/// Errors specific to git operations
30#[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/// Git reference selector for fetching specific refs.
82///
83/// This enum represents the different ways to specify which ref to checkout
84/// from a git repository, matching cargo's `GitReference` semantics.
85#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub enum GitSelector {
87    /// No branch has been explicitly specified, so sse the remote's default branch (fetches HEAD).
88    #[default]
89    DefaultBranch,
90    /// Explicit branch name.
91    Branch(String),
92    /// Explicit tag name.
93    Tag(String),
94    /// Explicit commit hash.
95    Commit(String),
96}
97
98/// Client for git operations using cached bare repositories and checkouts.
99///
100/// This type orchestrates all git operations through a two-tier cache:
101/// - Database cache: bare repos (one per URL) for efficient fetching
102/// - Checkout cache: working trees (one per commit) for final source code
103///
104/// The checkout path returned by [`GitClient::checkout_ref`] IS the final source code,
105/// ready to build. No additional copying is needed.
106#[derive(Clone, Debug)]
107pub(crate) struct GitClient {
108    cache: Cache,
109    reporter: MessageReporter,
110    http_config: HttpConfig,
111}
112
113impl GitClient {
114    /// Create a new [`GitClient`] with the given cache, message reporter, and HTTP config.
115    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    /// Checkout a git ref and return the path to the working tree.
124    ///
125    /// This uses a two-tier cache:
126    /// 1. Bare repository cache (one per URL) - for efficient fetching
127    /// 2. Checkout cache (one per commit) - the actual source code
128    ///
129    /// Returns a tuple of (`checkout_path`, `commit_hash`) where:
130    /// - `checkout_path`: Path to the checked-out working tree (the final source code)
131    /// - `commit_hash`: Full 40-character SHA-1 hash of the checked-out commit
132    pub(crate) fn checkout_ref(&self, url: &str, selector: GitSelector) -> Result<(PathBuf, String)> {
133        let db_path = self.ensure_db(url)?;
134
135        // About to check if ref exists locally
136        self.reporter.report(|| GitMessage::resolving_ref(url, &selector));
137
138        let commit_str = if let Ok(oid) = resolve_selector(&db_path, &selector) {
139            // Ref found locally - no network needed
140            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            // Ref not present - need to fetch from network
146            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        // Check if valid checkout exists (use .cgx-ok marker like cargo's .cargo-ok)
175        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        // Need to perform checkout - emit CheckingOut before extraction
182        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        // Mark as ready
196        let marker_path = checkout_path.join(".cgx-ok");
197        fs::write(&marker_path, "").with_context(|_| WriteMarkerFileSnafu {
198            path: marker_path.clone(),
199        })?;
200
201        // Extraction complete
202        self.reporter
203            .report(|| GitMessage::checkout_complete(&checkout_path));
204
205        Ok(checkout_path)
206    }
207}
208
209// Low-level git operations (private functions)
210
211fn 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
236/// Determine whether a failed fetch should be retried.
237///
238/// Only [`Error::FetchRef`] errors are candidates. We downcast the boxed source to the three
239/// concrete gix error types produced by [`fetch_ref_impl`] and delegate to gix's
240/// [`is_spurious()`](gix::protocol::transport::IsSpuriousError::is_spurious), which recursively
241/// inspects the error chain for transient conditions: 5xx HTTP status codes (mapped to
242/// `ConnectionAborted`), connection timeouts/resets/refused, curl transport failures (DNS, proxy,
243/// SSL, HTTP/2, partial file), broken pipe, interrupted, and unexpected EOF. It correctly returns
244/// `false` for 4xx errors like 401, 403, and 404.
245///
246/// One gap: gix maps HTTP 429 (Too Many Requests) to `io::ErrorKind::Other` which
247/// `is_spurious()` considers non-retryable. We want to retry on 429, so we also walk the
248/// error source chain looking for the `io::Error` with gix's exact format string.
249fn 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    // Check for HTTP 429 by walking the source chain for an io::Error with gix's exact message.
270    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    // `connectTimeout` only covers the TCP handshake. To also abort on stalled transfers
287    // (server accepted the connection but stops sending data), we set curl's low-speed
288    // threshold: if fewer than 1 byte/sec is sustained for `timeout` seconds, curl aborts
289    // with CURLE_OPERATION_TIMEDOUT, which gix surfaces as a spurious/retryable error.
290    let low_speed_time_secs = http_config.timeout.as_secs().max(1);
291
292    let mut overrides = vec![
293        // Controls the git protocol `agent` value (and acts as gix's fallback UA source).
294        // We set it so servers/proxies see cgx identity at the git protocol layer, not the
295        // default `git/oxide-*`. If omitted, protocol-layer identity reverts to gix default.
296        format!("gitoxide.userAgent={ua}").into(),
297        // Controls the HTTP backend's configured user-agent option (`http.userAgent`).
298        // This keeps transport-level UA settings aligned with cgx identity. If omitted,
299        // gix falls back to its default `oxide-*` transport agent for this setting.
300        format!("http.userAgent={ua}").into(),
301        // Forces an explicit `User-Agent` HTTP header on each request.
302        // This is currently required for our observed behavior with gix+curl: without this,
303        // requests in integration tests carry `User-Agent: git/oxide-*` instead of cgx UA.
304        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    // Build targeted refspec
331    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            // Full hash: try targeted fetch (may fail if commit not advertised)
337            // NOTE: This implementation assumes git servers support fetching arbitrary commits
338            // via protocol v2's allow-any-sha1-in-want capability (true for GitHub, GitLab.com).
339            // Servers that don't support this will fail for non-advertised commits.
340            // A fallback to broader fetch could be added if needed for restrictive servers.
341            // As of this writing I haven't even been able to *find* a public git server that
342            // doesn't support fetching arbitrary commits, so this is probably fine.
343            format!("+{c}:refs/commit/{c}")
344        }
345        GitSelector::Commit(_) => {
346            // Short hash or potentially unadvertised commit: fetch default branch with history
347            // so that we can search the commits and find the one that has this commit hash prefix.
348            "+HEAD:refs/remotes/origin/HEAD".to_string()
349        }
350    };
351
352    // Fetch with explicit refspec
353    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            // Peel annotated tags to get commit
426            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            // Use rev_parse_single to resolve both short and full commit hashes
438            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    // Get commit and tree
467    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    // Create index from tree
482    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    // Get checkout options (handles .gitattributes, filters, line endings)
490    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    // Use gix native checkout
500    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            // I happen to know what the commit hash is for this tag
618            assert_eq!("28d2bb04326d7036514245d73f10fb72b9ed108c", &commit_hash);
619        }
620
621        /// Checkout a specific commit that I happen to know is advertised by the remote, because
622        /// this commit is associated with the v6.0.0 tag.
623        #[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            // Known stable commit corresponding to tag v6.0.0
629            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            // Try again with a fresh client and clean cache, with a short commit; expect the same
639            // result
640            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        /// Checkout a specific commit that I happen to know just a regular commot that is NOT
657        /// adverstised by the remote.  This triggers fallback fetch logic and thus must be tested
658        /// separately from advertised commits.
659        #[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            // This is a random commit from 2024-07-02 that I don't think is advertised
665            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            // Try again with a fresh client and clean cache, with a short commit; expect the same
675            // result
676            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            // First checkout
699            let (first_checkout_path, first_checkout_hash) = git_client
700                .checkout_ref(url, GitSelector::Commit(commit.to_string()))
701                .unwrap();
702
703            // Second checkout should hit cache
704            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    /// Integration tests exercising the git fetch retry logic against a local mock HTTP server.
749    ///
750    /// These live here rather than in `cgx/tests/integration/` because the functions under test
751    /// ([`fetch_ref`], [`is_retryable_error`]) and their gix error types are `pub(crate)` and
752    /// not part of cgx-core's public API.
753    mod integration {
754        use std::time::Duration;
755
756        use httpmock::prelude::*;
757
758        use super::*;
759
760        /// Returns an HTTP configuration with near-zero retry delays so retry behavior can be
761        /// exercised without slowing the test suite down.
762        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        /// Returns an HTTP configuration that disables retries so one-shot request behavior can be
773        /// asserted deterministically.
774        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        /// Creates an empty bare repository to use as the destination object database for fetch
785        /// integration tests.
786        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}