fee7f9d093
## Summary Follow up to https://github.com/astral-sh/uv/pull/15563 Closes https://github.com/astral-sh/uv/issues/13485 This is a first-pass at adding support for conditional support for Git LFS between git sources, initial feedback welcome. e.g. ``` [tool.uv.sources] test-lfs-repo = { git = "https://github.com/zanieb/test-lfs-repo.git", lfs = true } ``` For context previously a user had to set `UV_GIT_LFS` to have uv fetch lfs objects on git sources. This env var was all or nothing, meaning you must always have it set to get consistent behavior and it applied to all git sources. If you fetched lfs objects at a revision and then turned off lfs (or vice versa), the git db, corresponding checkout lfs artifacts would not be updated properly. Similarly, when git source distributions were built, there would be no distinction between sources with lfs and without lfs. Hence, it could corrupt the git, sdist, and archive caches. In order to support some sources being LFS enabled and other not, this PR adds a stateful layer roughly similar to how `subdirectory` works but for `lfs` since the git database, the checkouts and the corresponding caching layers needed to be LFS aware (requested vs installed). The caches also had to isolated and treated entirely separate when handling LFS sources. Summary * Adds `lfs = true` or `lfs = false` to git sources in pyproject.toml * Added `lfs=true` query param / fragments to most relevant url structs (not parsed as user input) * In the case of uv add / uv tool, `--lfs` is supported instead * `UV_GIT_LFS` environment variable support is still functional for non-project entrypoints (e.g. uv pip) * `direct-url.json` now has an custom `git_lfs` entry under VcsInfo (note, this is not in the spec currently -- see caveats). * git database and checkouts have an different cache key as the sources should be treated effectively different for the same rev. * sdists cache also differ in the cache key of a built distribution if it was built using LFS enabled revisions to distinguish between non-LFS same revisions. This ensures the strong assumption for archive-v0 that an unpacked revision "doesn't change sources" stays valid. Caveats * `pylock.toml` import support has not been added via git_lfs=true, going through the spec it wasn't clear to me it's something we'd support outside of the env var (for now). * direct-url struct was modified by adding a non-standard `git_lfs` field under VcsInfo which may be undersirable although the PEP 610 does say `Additional fields that would be necessary to support such VCS SHOULD be prefixed with the VCS command name` which could be interpret this change as ok. * There will be a slight lockfile and cache churn for users that use `UV_GIT_LFS` as all git lockfile entries will get a `lfs=true` fragment. The cache version does not need an update, but LFS sources will get their own namespace under git-v0 and sdist-v9/git hence a cache-miss will occur once but this can be sufficient to label this as breaking for workflows always setting `UV_GIT_LFS`. ## Test Plan Some initial tests were added. More tests likely to follow as we reach consensus on a final approach. For IT test, we may want to move to use a repo under astral namespace in order to test lfs functionality. Manual testing was done for common pathological cases like killing LFS fetch mid-way, uninstalling LFS after installing an sdist with it and reinstalling, fetching LFS artifacts in different commits, etc. PSA: Please ignore the docker build failures as its related to depot OIDC issues. --------- Co-authored-by: Zanie Blue <contact@zanie.dev> Co-authored-by: konstin <konstin@mailbox.org>
226 lines
8.1 KiB
Rust
226 lines
8.1 KiB
Rust
//! Git support is derived from Cargo's implementation.
|
||
//! Cargo is dual-licensed under either Apache 2.0 or MIT, at the user's choice.
|
||
//! Source: <https://github.com/rust-lang/cargo/blob/23eb492cf920ce051abfc56bbaf838514dc8365c/src/cargo/sources/git/source.rs>
|
||
|
||
use std::borrow::Cow;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::Result;
|
||
use tracing::{debug, instrument};
|
||
|
||
use uv_cache_key::{RepositoryUrl, cache_digest};
|
||
use uv_git_types::{GitOid, GitReference, GitUrl};
|
||
use uv_redacted::DisplaySafeUrl;
|
||
|
||
use crate::GIT_STORE;
|
||
use crate::git::{GitDatabase, GitRemote};
|
||
|
||
/// A remote Git source that can be checked out locally.
|
||
pub struct GitSource {
|
||
/// The Git reference from the manifest file.
|
||
git: GitUrl,
|
||
/// Whether to disable SSL verification.
|
||
disable_ssl: bool,
|
||
/// Whether to operate without network connectivity.
|
||
offline: bool,
|
||
/// The path to the Git source database.
|
||
cache: PathBuf,
|
||
/// The reporter to use for this source.
|
||
reporter: Option<Arc<dyn Reporter>>,
|
||
}
|
||
|
||
impl GitSource {
|
||
/// Initialize a [`GitSource`] with the given Git URL, HTTP client, and cache path.
|
||
pub fn new(git: GitUrl, cache: impl Into<PathBuf>, offline: bool) -> Self {
|
||
Self {
|
||
git,
|
||
disable_ssl: false,
|
||
offline,
|
||
cache: cache.into(),
|
||
reporter: None,
|
||
}
|
||
}
|
||
|
||
/// Disable SSL verification for this [`GitSource`].
|
||
#[must_use]
|
||
pub fn dangerous(self) -> Self {
|
||
Self {
|
||
disable_ssl: true,
|
||
..self
|
||
}
|
||
}
|
||
|
||
/// Set the [`Reporter`] to use for the [`GitSource`].
|
||
#[must_use]
|
||
pub fn with_reporter(self, reporter: Arc<dyn Reporter>) -> Self {
|
||
Self {
|
||
reporter: Some(reporter),
|
||
..self
|
||
}
|
||
}
|
||
|
||
/// Fetch the underlying Git repository at the given revision.
|
||
#[instrument(skip(self), fields(repository = %self.git.repository(), rev = ?self.git.precise()))]
|
||
pub fn fetch(self) -> Result<Fetch> {
|
||
let lfs_requested = self.git.lfs().enabled();
|
||
|
||
// Compute the canonical URL for the repository.
|
||
let canonical = RepositoryUrl::new(self.git.repository());
|
||
|
||
// The path to the repo, within the Git database.
|
||
let ident = cache_digest(&canonical);
|
||
let db_path = self.cache.join("db").join(&ident);
|
||
|
||
// Authenticate the URL, if necessary.
|
||
let remote = if let Some(credentials) = GIT_STORE.get(&canonical) {
|
||
Cow::Owned(credentials.apply(self.git.repository().clone()))
|
||
} else {
|
||
Cow::Borrowed(self.git.repository())
|
||
};
|
||
|
||
// Fetch the commit, if we don't already have it. Wrapping this section in a closure makes
|
||
// it easier to short-circuit this in the cases where we do have the commit.
|
||
let (db, actual_rev, maybe_task) = || -> Result<(GitDatabase, GitOid, Option<usize>)> {
|
||
let git_remote = GitRemote::new(&remote);
|
||
let maybe_db = git_remote.db_at(&db_path).ok();
|
||
|
||
// If we have a locked revision, and we have a pre-existing database which has that
|
||
// revision, then no update needs to happen.
|
||
// When requested, we also check if LFS artifacts have been fetched and validated.
|
||
if let (Some(rev), Some(db)) = (self.git.precise(), &maybe_db) {
|
||
if db.contains(rev) && (!lfs_requested || db.contains_lfs_artifacts(rev)) {
|
||
debug!("Using existing Git source `{}`", self.git.repository());
|
||
return Ok((
|
||
maybe_db
|
||
.unwrap()
|
||
.with_lfs_ready(lfs_requested.then_some(true)),
|
||
rev,
|
||
None,
|
||
));
|
||
}
|
||
}
|
||
|
||
// If the revision isn't locked, but it looks like it might be an exact commit hash,
|
||
// and we do have a pre-existing database, then check whether it is, in fact, a commit
|
||
// hash. If so, treat it like it's locked.
|
||
// When requested, we also check if LFS artifacts have been fetched and validated.
|
||
if let Some(db) = &maybe_db {
|
||
if let GitReference::BranchOrTagOrCommit(maybe_commit) = self.git.reference() {
|
||
if let Ok(oid) = maybe_commit.parse::<GitOid>() {
|
||
if db.contains(oid) && (!lfs_requested || db.contains_lfs_artifacts(oid)) {
|
||
// This reference is an exact commit. Treat it like it's locked.
|
||
debug!("Using existing Git source `{}`", self.git.repository());
|
||
return Ok((
|
||
maybe_db
|
||
.unwrap()
|
||
.with_lfs_ready(lfs_requested.then_some(true)),
|
||
oid,
|
||
None,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ... otherwise, we use this state to update the Git database. Note that we still check
|
||
// for being offline here, for example in the situation that we have a locked revision
|
||
// but the database doesn't have it.
|
||
debug!("Updating Git source `{}`", self.git.repository());
|
||
|
||
// Report the checkout operation to the reporter.
|
||
let task = self.reporter.as_ref().map(|reporter| {
|
||
reporter.on_checkout_start(git_remote.url(), self.git.reference().as_rev())
|
||
});
|
||
|
||
let (db, actual_rev) = git_remote.checkout(
|
||
&db_path,
|
||
maybe_db,
|
||
self.git.reference(),
|
||
self.git.precise(),
|
||
self.disable_ssl,
|
||
self.offline,
|
||
lfs_requested,
|
||
)?;
|
||
|
||
Ok((db, actual_rev, task))
|
||
}()?;
|
||
|
||
// Don’t use the full hash, in order to contribute less to reaching the
|
||
// path length limit on Windows.
|
||
let short_id = db.to_short_id(actual_rev)?;
|
||
|
||
// Compute the canonical URL for the repository checkout.
|
||
let canonical = canonical.with_lfs(Some(lfs_requested));
|
||
// Recompute the checkout hash when Git LFS is enabled as we want
|
||
// to distinctly differentiate between LFS vs non-LFS source trees.
|
||
let ident = if lfs_requested {
|
||
cache_digest(&canonical)
|
||
} else {
|
||
ident
|
||
};
|
||
let checkout_path = self
|
||
.cache
|
||
.join("checkouts")
|
||
.join(&ident)
|
||
.join(short_id.as_str());
|
||
|
||
// Check out `actual_rev` from the database to a scoped location on the
|
||
// filesystem. This will use hard links and such to ideally make the
|
||
// checkout operation here pretty fast.
|
||
let checkout = db.copy_to(actual_rev, &checkout_path)?;
|
||
|
||
// Report the checkout operation to the reporter.
|
||
if let Some(task) = maybe_task {
|
||
if let Some(reporter) = self.reporter.as_ref() {
|
||
reporter.on_checkout_complete(remote.as_ref(), actual_rev.as_str(), task);
|
||
}
|
||
}
|
||
|
||
Ok(Fetch {
|
||
git: self.git.with_precise(actual_rev),
|
||
path: checkout_path,
|
||
lfs_ready: checkout.lfs_ready().unwrap_or(false),
|
||
})
|
||
}
|
||
}
|
||
|
||
pub struct Fetch {
|
||
/// The [`GitUrl`] reference that was fetched.
|
||
git: GitUrl,
|
||
/// The path to the checked out repository.
|
||
path: PathBuf,
|
||
/// Git LFS artifacts have been initialized (if requested).
|
||
lfs_ready: bool,
|
||
}
|
||
|
||
impl Fetch {
|
||
pub fn git(&self) -> &GitUrl {
|
||
&self.git
|
||
}
|
||
|
||
pub fn path(&self) -> &Path {
|
||
&self.path
|
||
}
|
||
|
||
pub fn lfs_ready(&self) -> &bool {
|
||
&self.lfs_ready
|
||
}
|
||
|
||
pub fn into_git(self) -> GitUrl {
|
||
self.git
|
||
}
|
||
|
||
pub fn into_path(self) -> PathBuf {
|
||
self.path
|
||
}
|
||
}
|
||
|
||
pub trait Reporter: Send + Sync {
|
||
/// Callback to invoke when a repository checkout begins.
|
||
fn on_checkout_start(&self, url: &DisplaySafeUrl, rev: &str) -> usize;
|
||
|
||
/// Callback to invoke when a repository checkout completes.
|
||
fn on_checkout_complete(&self, url: &DisplaySafeUrl, rev: &str, index: usize);
|
||
}
|