Skip to main content

cargo/sources/git/
source.rs

1//! See [`GitSource`].
2
3use crate::sources::IndexSummary;
4use crate::sources::RecursivePathSource;
5use crate::sources::git::utils::GitDatabase;
6use crate::sources::git::utils::GitRemote;
7use crate::sources::git::utils::rev_to_oid;
8use crate::sources::source::MaybePackage;
9use crate::sources::source::QueryKind;
10use crate::sources::source::Source;
11use crate::util::GlobalContext;
12use crate::util::cache_lock::CacheLockMode;
13use crate::util::errors::CargoResult;
14use crate::util::hex::short_hash;
15use crate::util::interning::InternedString;
16use crate::workspace::GitReference;
17use crate::workspace::SourceId;
18use crate::workspace::global_cache_tracker;
19use crate::workspace::{Dependency, Package, PackageId};
20use anyhow::Context as _;
21use cargo_util::paths::exclude_from_backups_and_indexing;
22use std::cell::RefCell;
23use std::fmt::{self, Debug, Formatter};
24use tracing::trace;
25use url::Url;
26
27/// `GitSource` contains one or more packages gathering from a Git repository.
28/// Under the hood it uses [`RecursivePathSource`] to discover packages inside the
29/// repository.
30///
31/// ## Filesystem layout
32///
33/// During a successful `GitSource` download, at least two Git repositories are
34/// created: one is the shared Git database of this remote, and the other is the
35/// Git checkout to a specific revision, which contains the actual files to be
36/// compiled. Multiple checkouts can be cloned from a single Git database.
37///
38/// Those repositories are located at Cargo's Git cache directory
39/// `$CARGO_HOME/git`. The file tree of the cache directory roughly looks like:
40///
41/// ```text
42/// $CARGO_HOME/git/
43/// ├── checkouts/
44/// │  ├── gimli-a0d193bd15a5ed96/
45/// │  │  ├── 8e73ef0/     # Git short ID for a certain revision
46/// │  │  ├── a2a4b78/
47/// │  │  └── e33d1ac/
48/// │  ├── log-c58e1db3de7c154d-shallow/
49/// │  │  └── 11eda98/
50/// └── db/
51///    ├── gimli-a0d193bd15a5ed96/
52///    └── log-c58e1db3de7c154d-shallow/
53/// ```
54///
55/// For more on Git cache directory, see ["Cargo Home"] in The Cargo Book.
56///
57/// For more on the directory format `<pkg>-<hash>[-shallow]`, see [`ident`]
58/// and [`ident_shallow`].
59///
60/// ## Locked to a revision
61///
62/// Once a `GitSource` is fetched, it will resolve to a specific commit revision.
63/// This is often mentioned as "locked revision" (`locked_rev`) throughout the
64/// codebase. The revision is written into `Cargo.lock`. This is essential since
65/// we want to ensure a package can compiles with the same set of files when
66/// a `Cargo.lock` is present. With the `locked_rev` provided, `GitSource` can
67/// precisely fetch the same revision from the Git repository.
68///
69/// ["Cargo Home"]: https://doc.rust-lang.org/nightly/cargo/guide/cargo-home.html#directories
70pub struct GitSource<'gctx> {
71    /// The git remote which we're going to fetch from.
72    remote: GitRemote,
73    /// The revision which a git source is locked to.
74    ///
75    /// Expected to always be [`Revision::Locked`] after the Git repository is fetched.
76    locked_rev: RefCell<Revision>,
77    /// The unique identifier of this source.
78    source_id: RefCell<SourceId>,
79    /// The underlying path source to discover packages inside the Git repository.
80    ///
81    /// This gets set to `Some` after the git repo has been checked out
82    /// (automatically handled via [`GitSource::update`]).
83    path_source: RefCell<Option<RecursivePathSource<'gctx>>>,
84    /// A short string that uniquely identifies the version of the checkout.
85    ///
86    /// This is typically a 7-character string of the OID hash, automatically
87    /// increasing in size if it is ambiguous.
88    ///
89    /// This is set to `Some` after the git repo has been checked out
90    /// (automatically handled via [`GitSource::update`]).
91    short_id: RefCell<Option<InternedString>>,
92    /// The identifier of this source for Cargo's Git cache directory.
93    /// See [`ident`] for more.
94    ident: InternedString,
95    gctx: &'gctx GlobalContext,
96    /// Disables status messages.
97    quiet: bool,
98}
99
100impl<'gctx> GitSource<'gctx> {
101    /// Creates a git source for the given [`SourceId`].
102    pub fn new(source_id: SourceId, gctx: &'gctx GlobalContext) -> CargoResult<GitSource<'gctx>> {
103        let remote = GitRemote::new(source_id.url());
104        Self::new_with_remote(source_id, remote, gctx)
105    }
106
107    /// Creates a git source for a submodule with an URL that may not be a valid WHATWG URL.
108    ///
109    /// This is needed because [`SourceId`] hasn't yet supported SCP-like URLs.
110    pub(super) fn new_for_submodule(
111        source_id: SourceId,
112        fetch_url: String,
113        gctx: &'gctx GlobalContext,
114    ) -> CargoResult<GitSource<'gctx>> {
115        let remote = GitRemote::new_from_str(fetch_url);
116        Self::new_with_remote(source_id, remote, gctx)
117    }
118
119    fn new_with_remote(
120        source_id: SourceId,
121        remote: GitRemote,
122        gctx: &'gctx GlobalContext,
123    ) -> CargoResult<GitSource<'gctx>> {
124        assert!(source_id.is_git(), "id is not git, id={}", source_id);
125
126        // Fallback to git ref from manifest if there is no locked revision.
127        let locked_rev = source_id
128            .precise_git_fragment()
129            .map(|s| Revision::new(s.into()))
130            .unwrap_or_else(|| source_id.git_reference().unwrap().clone().into());
131
132        let ident = ident_shallow(
133            &source_id,
134            gctx.cli_unstable()
135                .git
136                .map_or(false, |features| features.shallow_deps),
137        );
138
139        let source = GitSource {
140            remote,
141            locked_rev: RefCell::new(locked_rev),
142            source_id: RefCell::new(source_id),
143            path_source: RefCell::new(None),
144            short_id: RefCell::new(None),
145            ident: ident.into(),
146            gctx,
147            quiet: false,
148        };
149
150        Ok(source)
151    }
152
153    /// Gets the remote repository URL.
154    pub fn url(&self) -> Url {
155        self.source_id.borrow().url().clone()
156    }
157
158    /// Returns the packages discovered by this source. It may fetch the Git
159    /// repository as well as walk the filesystem if package information
160    /// haven't yet updated.
161    pub fn read_packages(&self) -> CargoResult<Vec<Package>> {
162        if self.path_source.borrow().is_none() {
163            self.invalidate_cache();
164            self.update()?;
165        }
166        self.path_source.borrow().as_ref().unwrap().read_packages()
167    }
168
169    fn mark_used(&self) -> CargoResult<()> {
170        self.gctx
171            .deferred_global_last_use()?
172            .mark_git_checkout_used(global_cache_tracker::GitCheckout {
173                encoded_git_name: self.ident,
174                short_name: self.short_id.borrow().expect("update before download"),
175                size: None,
176            });
177        Ok(())
178    }
179
180    /// Fetch and return a [`GitDatabase`] with the resolved revision
181    /// for this source,
182    ///
183    /// This won't fetch anything if the required revision is
184    /// already available locally.
185    pub(crate) fn fetch_db(&self, is_submodule: bool) -> CargoResult<(GitDatabase, git2::Oid)> {
186        let db_path = self.gctx.git_db_path().join(&self.ident);
187        let db_path = db_path.into_path_unlocked();
188
189        let db = self.remote.db_at(&db_path).ok();
190
191        let (db, actual_rev) = match (&*self.locked_rev.borrow(), db) {
192            // If we have a locked revision, and we have a preexisting database
193            // which has that revision, then no update needs to happen.
194            (Revision::Locked(oid), Some(db)) if db.contains(*oid) => (db, *oid),
195
196            // If we're in offline mode, we're not locked, and we have a
197            // database, then try to resolve our reference with the preexisting
198            // repository.
199            (Revision::Deferred(git_ref), Some(db)) if !self.gctx.network_allowed() => {
200                let offline_flag = self
201                    .gctx
202                    .offline_flag()
203                    .expect("always present when `!network_allowed`");
204                let rev = db.resolve(&git_ref).with_context(|| {
205                    format!(
206                        "failed to lookup reference in preexisting repository, and \
207                         can't check for updates in offline mode ({offline_flag})"
208                    )
209                })?;
210                (db, rev)
211            }
212
213            // ... otherwise we use this state to update the git database. Note
214            // that we still check for being offline here, for example in the
215            // situation that we have a locked revision but the database
216            // doesn't have it.
217            (locked_rev, db) => {
218                if let Some(offline_flag) = self.gctx.offline_flag() {
219                    anyhow::bail!(
220                        "can't checkout from '{}': you are in the offline mode ({offline_flag})",
221                        self.remote.url()
222                    );
223                }
224
225                if !self.quiet {
226                    let scope = if is_submodule {
227                        "submodule"
228                    } else {
229                        "repository"
230                    };
231                    self.gctx
232                        .shell()
233                        .status("Updating", format!("git {scope} `{}`", self.remote.url()))?;
234                }
235
236                trace!("updating git source `{:?}`", self.remote);
237
238                let locked_rev = locked_rev.clone().into();
239                let manifest_reference = self.source_id.borrow().git_reference().unwrap();
240                self.remote
241                    .checkout(&db_path, db, manifest_reference, &locked_rev, self.gctx)?
242            }
243        };
244        Ok((db, actual_rev))
245    }
246
247    fn update(&self) -> CargoResult<()> {
248        if self.path_source.borrow().is_some() {
249            self.mark_used()?;
250            return Ok(());
251        }
252
253        let git_fs = self.gctx.git_path();
254        // Ignore errors creating it, in case this is a read-only filesystem:
255        // perhaps the later operations can succeed anyhow.
256        let _ = git_fs.create_dir();
257        let git_path = self
258            .gctx
259            .assert_package_cache_locked(CacheLockMode::DownloadExclusive, &git_fs);
260
261        // Before getting a checkout, make sure that `<cargo_home>/git` is
262        // marked as excluded from indexing and backups. Older versions of Cargo
263        // didn't do this, so we do it here regardless of whether `<cargo_home>`
264        // exists.
265        //
266        // This does not use `create_dir_all_excluded_from_backups_atomic` for
267        // the same reason: we want to exclude it even if the directory already
268        // exists.
269        exclude_from_backups_and_indexing(&git_path);
270
271        let (db, actual_rev) = self.fetch_db(false)?;
272
273        // Don’t use the full hash, in order to contribute less to reaching the
274        // path length limit on Windows. See
275        // <https://github.com/servo/servo/pull/14397>.
276        let short_id = db.to_short_id(actual_rev)?;
277
278        // Check out `actual_rev` from the database to a scoped location on the
279        // filesystem. This will use hard links and such to ideally make the
280        // checkout operation here pretty fast.
281        let checkout_path = self
282            .gctx
283            .git_checkouts_path()
284            .join(&self.ident)
285            .join(short_id.as_str());
286        let checkout_path = checkout_path.into_path_unlocked();
287        db.copy_to(actual_rev, &checkout_path, self.gctx, self.quiet)?;
288
289        let source_id = self
290            .source_id
291            .borrow()
292            .with_git_precise(Some(actual_rev.to_string()));
293        let path_source = RecursivePathSource::new(&checkout_path, source_id, self.gctx);
294
295        self.path_source.replace(Some(path_source));
296        self.short_id.replace(Some(short_id.as_str().into()));
297        self.locked_rev.replace(Revision::Locked(actual_rev));
298        self.path_source.borrow().as_ref().unwrap().load()?;
299
300        self.mark_used()?;
301        Ok(())
302    }
303}
304
305/// Indicates a [Git revision] that might be locked or deferred to be resolved.
306///
307/// [Git revision]: https://git-scm.com/docs/revisions
308#[derive(Clone, Debug)]
309enum Revision {
310    /// A [Git reference] that would trigger extra fetches when being resolved.
311    ///
312    /// [Git reference]: https://git-scm.com/book/en/v2/Git-Internals-Git-References
313    Deferred(GitReference),
314    /// A locked revision of the actual Git commit object ID.
315    Locked(git2::Oid),
316}
317
318impl Revision {
319    fn new(rev: &str) -> Revision {
320        match rev_to_oid(rev) {
321            Some(oid) => Revision::Locked(oid),
322            None => Revision::Deferred(GitReference::Rev(rev.to_string())),
323        }
324    }
325}
326
327impl From<GitReference> for Revision {
328    fn from(value: GitReference) -> Self {
329        Revision::Deferred(value)
330    }
331}
332
333impl From<Revision> for GitReference {
334    fn from(value: Revision) -> Self {
335        match value {
336            Revision::Deferred(git_ref) => git_ref,
337            Revision::Locked(oid) => GitReference::Rev(oid.to_string()),
338        }
339    }
340}
341
342/// Create an identifier from a URL,
343/// essentially turning `proto://host/path/repo` into `repo-<hash-of-url>`.
344fn ident(id: &SourceId) -> String {
345    let ident = id
346        .canonical_url()
347        .raw_canonicalized_url()
348        .path_segments()
349        .and_then(|s| s.rev().next())
350        .unwrap_or("");
351
352    let ident = if ident.is_empty() { "_empty" } else { ident };
353
354    format!("{}-{}", ident, short_hash(id.canonical_url()))
355}
356
357/// Like [`ident()`], but appends `-shallow` to it, turning
358/// `proto://host/path/repo` into `repo-<hash-of-url>-shallow`.
359///
360/// It's important to separate shallow from non-shallow clones for reasons of
361/// backwards compatibility --- older cargo's aren't necessarily handling
362/// shallow clones correctly.
363fn ident_shallow(id: &SourceId, is_shallow: bool) -> String {
364    let mut ident = ident(id);
365    if is_shallow {
366        ident.push_str("-shallow");
367    }
368    ident
369}
370
371impl<'gctx> Debug for GitSource<'gctx> {
372    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
373        write!(f, "git repo at {}", self.source_id.borrow().url())?;
374        match &*self.locked_rev.borrow() {
375            Revision::Deferred(git_ref) => match git_ref.pretty_ref(true) {
376                Some(s) => write!(f, " ({})", s),
377                None => Ok(()),
378            },
379            Revision::Locked(oid) => write!(f, " ({oid})"),
380        }
381    }
382}
383
384#[async_trait::async_trait(?Send)]
385impl<'gctx> Source for GitSource<'gctx> {
386    async fn query(
387        &self,
388        dep: &Dependency,
389        kind: QueryKind,
390        f: &mut dyn FnMut(IndexSummary),
391    ) -> CargoResult<()> {
392        if self.path_source.borrow().is_none() {
393            self.update()?;
394        }
395        let src = self.path_source.borrow();
396        let src = src.as_ref().unwrap();
397        src.query(dep, kind, f).await
398    }
399
400    fn supports_checksums(&self) -> bool {
401        false
402    }
403
404    fn requires_precise(&self) -> bool {
405        true
406    }
407
408    fn source_id(&self) -> SourceId {
409        *self.source_id.borrow()
410    }
411
412    async fn download(&self, id: PackageId) -> CargoResult<MaybePackage> {
413        trace!(
414            "getting packages for package ID `{}` from `{:?}`",
415            id, self.remote
416        );
417        self.mark_used()?;
418        self.path_source
419            .borrow_mut()
420            .as_mut()
421            .expect("BUG: `update()` must be called before `get()`")
422            .download(id)
423            .await
424    }
425
426    async fn finish_download(&self, _id: PackageId, _data: Vec<u8>) -> CargoResult<Package> {
427        panic!("no download should have started")
428    }
429
430    fn fingerprint(&self, _pkg: &Package) -> CargoResult<String> {
431        match &*self.locked_rev.borrow() {
432            Revision::Locked(oid) => Ok(oid.to_string()),
433            _ => unreachable!("locked_rev must be resolved when computing fingerprint"),
434        }
435    }
436
437    fn describe(&self) -> String {
438        format!("Git repository {}", self.source_id.borrow())
439    }
440
441    fn invalidate_cache(&self) {}
442
443    fn set_quiet(&mut self, quiet: bool) {
444        self.quiet = quiet;
445    }
446}
447
448#[cfg(test)]
449mod test {
450    use super::ident;
451    use crate::util::IntoUrl;
452    use crate::workspace::{GitReference, SourceId};
453
454    #[test]
455    pub fn test_url_to_path_ident_with_path() {
456        let ident = ident(&src("https://github.com/carlhuda/cargo"));
457        assert!(ident.starts_with("cargo-"));
458    }
459
460    #[test]
461    pub fn test_url_to_path_ident_without_path() {
462        let ident = ident(&src("https://github.com"));
463        assert!(ident.starts_with("_empty-"));
464    }
465
466    #[test]
467    fn test_canonicalize_idents_by_stripping_trailing_url_slash() {
468        let ident1 = ident(&src("https://github.com/PistonDevelopers/piston/"));
469        let ident2 = ident(&src("https://github.com/PistonDevelopers/piston"));
470        assert_eq!(ident1, ident2);
471    }
472
473    #[test]
474    fn test_canonicalize_idents_by_lowercasing_github_urls() {
475        let ident1 = ident(&src("https://github.com/PistonDevelopers/piston"));
476        let ident2 = ident(&src("https://github.com/pistondevelopers/piston"));
477        assert_eq!(ident1, ident2);
478    }
479
480    #[test]
481    fn test_canonicalize_idents_by_stripping_dot_git() {
482        let ident1 = ident(&src("https://github.com/PistonDevelopers/piston"));
483        let ident2 = ident(&src("https://github.com/PistonDevelopers/piston.git"));
484        assert_eq!(ident1, ident2);
485    }
486
487    #[test]
488    fn test_canonicalize_idents_different_protocols() {
489        let ident1 = ident(&src("https://github.com/PistonDevelopers/piston"));
490        let ident2 = ident(&src("git://github.com/PistonDevelopers/piston"));
491        assert_eq!(ident1, ident2);
492    }
493
494    #[test]
495    fn test_canonicalize_idents_does_not_strip_dot_git_for_sparse() {
496        let ident1 = ident(&src("sparse+https://crates.io/fake-registry"));
497        let ident2 = ident(&src("sparse+https://crates.io/fake-registry.git"));
498        assert_ne!(ident1, ident2);
499    }
500
501    fn src(s: &str) -> SourceId {
502        SourceId::for_git(&s.into_url().unwrap(), GitReference::DefaultBranch).unwrap()
503    }
504}