diff --git a/crates/uv-cache-info/src/git_info.rs b/crates/uv-cache-info/src/git_info.rs index ad566d13bdf91..10bbafdda058f 100644 --- a/crates/uv-cache-info/src/git_info.rs +++ b/crates/uv-cache-info/src/git_info.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use tracing::warn; @@ -10,8 +11,8 @@ pub(crate) enum GitInfoError { MissingGitDir(PathBuf), #[error("The repository at {0} is missing a `HEAD` file")] MissingHead(PathBuf), - #[error("The repository at {0} is missing a `refs` directory")] - MissingRefs(PathBuf), + #[error("The repository at {0} is missing the reference `{1}`")] + MissingRef(PathBuf, String), #[error("The repository at {0} has an invalid reference: `{1}`")] InvalidRef(PathBuf, String), #[error("The discovered commit has an invalid length (expected 40 characters): `{0}`")] @@ -29,41 +30,30 @@ pub(crate) struct Commit(String); impl Commit { /// Return the [`Commit`] for the repository at the given path. pub(crate) fn from_repository(path: &Path) -> Result { - // Find the `.git` directory, searching through parent directories if necessary. - let git_dir = path - .ancestors() - .map(|ancestor| ancestor.join(".git")) - .find(|git_dir| git_dir.exists()) - .ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?; + let repository = GitRepository::find(path)?; - let git_head_path = - git_head(&git_dir).ok_or_else(|| GitInfoError::MissingHead(git_dir.clone()))?; + let git_head_path = repository.git_dir.join("HEAD"); + if !git_head_path.exists() { + return Err(GitInfoError::MissingHead(repository.git_dir)); + } let git_head_contents = fs_err::read_to_string(git_head_path)?; // The contents are either a commit or a reference in the following formats // - "" when the head is detached - // - "ref " when working on a branch + // - "ref: " when working on a branch // If a commit, checking if the HEAD file has changed is sufficient // If a ref, we need to add the head file for that ref to rebuild on commit let mut git_ref_parts = git_head_contents.split_whitespace(); - let commit_or_ref = git_ref_parts - .next() - .ok_or_else(|| GitInfoError::InvalidRef(git_dir.clone(), git_head_contents.clone()))?; + let commit_or_ref = git_ref_parts.next().ok_or_else(|| { + GitInfoError::InvalidRef(repository.git_dir.clone(), git_head_contents.clone()) + })?; let commit = if let Some(git_ref) = git_ref_parts.next() { - let git_ref_path = git_dir.join(git_ref); - let commit = fs_err::read_to_string(git_ref_path)?; - commit.trim().to_string() + repository.read_ref(git_ref)? } else { commit_or_ref.to_string() }; - // The commit should be 40 hexadecimal characters. - if commit.len() != 40 { - return Err(GitInfoError::WrongLength(commit)); - } - if commit.chars().any(|c| !c.is_ascii_hexdigit()) { - return Err(GitInfoError::WrongDigit(commit)); - } + validate_commit(&commit)?; Ok(Self(commit)) } @@ -76,44 +66,36 @@ pub(crate) struct Tags(BTreeMap); impl Tags { /// Return the [`Tags`] for the repository at the given path. pub(crate) fn from_repository(path: &Path) -> Result { - // Find the `.git` directory, searching through parent directories if necessary. - let git_dir = path - .ancestors() - .map(|ancestor| ancestor.join(".git")) - .find(|git_dir| git_dir.exists()) - .ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?; - - let git_tags_path = git_refs(&git_dir) - .ok_or_else(|| GitInfoError::MissingRefs(git_dir.clone()))? - .join("tags"); + let repository = GitRepository::find(path)?; + let git_tags_path = repository.common_dir.join("refs").join("tags"); let mut tags = BTreeMap::new(); - // Map each tag to its commit. - for entry in WalkDir::new(&git_tags_path).contents_first(true) { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - warn!("Failed to read Git tags: {err}"); - continue; - } - }; - let path = entry.path(); - if !entry.file_type().is_file() { - continue; + for (git_ref, commit) in read_packed_refs(&repository.common_dir)? { + if let Some(tag) = git_ref.strip_prefix("refs/tags/") { + tags.insert(tag.to_string(), commit); } - if let Ok(Some(tag)) = path.strip_prefix(&git_tags_path).map(|name| name.to_str()) { - let commit = fs_err::read_to_string(path)?.trim().to_string(); + } - // The commit should be 40 hexadecimal characters. - if commit.len() != 40 { - return Err(GitInfoError::WrongLength(commit)); + // Map each tag to its commit. + if git_tags_path.exists() { + for entry in WalkDir::new(&git_tags_path).contents_first(true) { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + warn!("Failed to read Git tags: {err}"); + continue; + } + }; + let path = entry.path(); + if !entry.file_type().is_file() { + continue; } - if commit.chars().any(|c| !c.is_ascii_hexdigit()) { - return Err(GitInfoError::WrongDigit(commit)); + if let Ok(Some(tag)) = path.strip_prefix(&git_tags_path).map(|name| name.to_str()) + && let Some(commit) = read_ref_file(path)? + { + tags.insert(tag.to_string(), commit); } - - tags.insert(tag.to_string(), commit); } } @@ -121,59 +103,196 @@ impl Tags { } } -/// Return the path to the `HEAD` file of a Git repository, taking worktrees into account. -fn git_head(git_dir: &Path) -> Option { - // The typical case is a standard git repository. - let git_head_path = git_dir.join("HEAD"); - if git_head_path.exists() { - return Some(git_head_path); +struct GitRepository { + git_dir: PathBuf, + common_dir: PathBuf, +} + +impl GitRepository { + /// Find the Git repository for a path, searching parent directories if necessary. + fn find(path: &Path) -> Result { + let dot_git_path = path + .ancestors() + .map(|ancestor| ancestor.join(".git")) + .find(|dot_git_path| dot_git_path.exists()) + .ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?; + let git_dir = read_git_dir(&dot_git_path) + .ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?; + let common_dir = read_common_dir(&git_dir)?; + + Ok(Self { + git_dir, + common_dir, + }) } - if !git_dir.is_file() { - return None; + + /// Resolve a Git ref to a commit, taking worktrees and packed refs into account. + fn read_ref(&self, git_ref: &str) -> Result { + if let Some(commit) = read_ref_file(&self.git_dir.join(git_ref))? { + return Ok(commit); + } + if let Some(commit) = read_ref_file(&self.common_dir.join(git_ref))? { + return Ok(commit); + } + if let Some(commit) = read_packed_refs(&self.common_dir)?.remove(git_ref) { + return Ok(commit); + } + Err(GitInfoError::MissingRef( + self.common_dir.clone(), + git_ref.to_string(), + )) + } +} + +/// Resolve `.git` to the repository's Git directory, including linked-worktree files. +fn read_git_dir(dot_git_path: &Path) -> Option { + if dot_git_path.is_dir() { + return Some(dot_git_path.to_path_buf()); } - // If `.git/HEAD` doesn't exist and `.git` is actually a file, - // then let's try to attempt to read it as a worktree. If it's - // a worktree, then its contents will look like this, e.g.: - // - // gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2 - // - // And the HEAD file we want to watch will be at: - // - // /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD - let contents = fs_err::read_to_string(git_dir).ok()?; - let (label, worktree_path) = contents.split_once(':')?; - if label != "gitdir" { + if !dot_git_path.is_file() { return None; } - let worktree_path = worktree_path.trim(); - Some(PathBuf::from(worktree_path)) + + let contents = fs_err::read_to_string(dot_git_path).ok()?; + let git_dir = contents.strip_prefix("gitdir:")?.trim(); + Some(resolve_relative_path(dot_git_path.parent()?, git_dir)) +} + +/// Return the common Git directory, following a linked worktree's `commondir` file. +fn read_common_dir(git_dir: &Path) -> Result { + let commondir_path = git_dir.join("commondir"); + let contents = match fs_err::read_to_string(commondir_path) { + Ok(contents) => contents, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(git_dir.to_path_buf()), + Err(err) => return Err(err.into()), + }; + Ok(resolve_relative_path(git_dir, contents.trim())) +} + +/// Read and validate a loose ref, returning [`None`] when it does not exist. +fn read_ref_file(path: &Path) -> Result, GitInfoError> { + let contents = match fs_err::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let commit = contents.trim().to_string(); + validate_commit(&commit)?; + Ok(Some(commit)) } -/// Return the path to the `refs` directory of a Git repository, taking worktrees into account. -fn git_refs(git_dir: &Path) -> Option { - // The typical case is a standard git repository. - let git_head_path = git_dir.join("refs"); - if git_head_path.exists() { - return Some(git_head_path); +/// Read the direct refs from `packed-refs`, ignoring comments and peeled tag lines. +fn read_packed_refs(git_dir: &Path) -> Result, GitInfoError> { + let path = git_dir.join("packed-refs"); + let contents = match fs_err::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(err) => return Err(err.into()), + }; + + let mut refs = BTreeMap::new(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('^') { + continue; + } + + let (commit, git_ref) = line + .split_once(' ') + .ok_or_else(|| GitInfoError::InvalidRef(git_dir.to_path_buf(), line.to_string()))?; + validate_commit(commit)?; + refs.insert(git_ref.to_string(), commit.to_string()); } - if !git_dir.is_file() { - return None; + + Ok(refs) +} + +fn resolve_relative_path(base: &Path, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + base.join(path) } - // If `.git/refs` doesn't exist and `.git` is actually a file, - // then let's try to attempt to read it as a worktree. If it's - // a worktree, then its contents will look like this, e.g.: - // - // gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2 - // - // And the HEAD refs we want to watch will be at: - // - // /home/andrew/astral/uv/main/.git/refs - let contents = fs_err::read_to_string(git_dir).ok()?; - let (label, worktree_path) = contents.split_once(':')?; - if label != "gitdir" { - return None; +} + +fn validate_commit(commit: &str) -> Result<(), GitInfoError> { + // The commit should be 40 hexadecimal characters. + if commit.len() != 40 { + return Err(GitInfoError::WrongLength(commit.to_string())); + } + if commit.chars().any(|c| !c.is_ascii_hexdigit()) { + return Err(GitInfoError::WrongDigit(commit.to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use anyhow::Result; + + use super::{Commit, Tags}; + + const COMMIT_1: &str = "1b6638fdb424e993d8354e75c55a3e524050c857"; + const COMMIT_2: &str = "a1a42cbd10d83bafd8600ba81f72bbef6c579385"; + + #[test] + fn commit_and_tags_from_linked_worktree() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let worktree = temp_dir.path().join("worktree"); + let common_git_dir = temp_dir.path().join("common.git"); + let worktree_git_dir = common_git_dir.join("worktrees").join("worktree"); + + fs_err::create_dir_all(&worktree)?; + fs_err::create_dir_all(&worktree_git_dir)?; + fs_err::write( + worktree.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + )?; + fs_err::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/main\n")?; + fs_err::write(worktree_git_dir.join("commondir"), "../..\n")?; + fs_err::write( + common_git_dir.join("packed-refs"), + format!( + "\ +# pack-refs with: peeled fully-peeled sorted +{COMMIT_1} refs/heads/main +{COMMIT_2} refs/tags/v0.1.0 +^{COMMIT_1} +" + ), + )?; + + let mut expected_tags = BTreeMap::new(); + expected_tags.insert("v0.1.0".to_string(), COMMIT_2.to_string()); + + assert_eq!( + Commit::from_repository(&worktree)?, + Commit(COMMIT_1.to_string()) + ); + assert_eq!( + Tags::from_repository(&worktree)?, + Tags(expected_tags.clone()) + ); + + let refs_dir = common_git_dir.join("refs"); + let heads_dir = refs_dir.join("heads"); + let tags_dir = refs_dir.join("tags"); + fs_err::create_dir_all(&heads_dir)?; + fs_err::create_dir_all(&tags_dir)?; + fs_err::write(heads_dir.join("main"), COMMIT_2)?; + fs_err::write(tags_dir.join("v0.1.0"), COMMIT_1)?; + + expected_tags.insert("v0.1.0".to_string(), COMMIT_1.to_string()); + + assert_eq!( + Commit::from_repository(&worktree)?, + Commit(COMMIT_2.to_string()) + ); + assert_eq!(Tags::from_repository(&worktree)?, Tags(expected_tags)); + + Ok(()) } - let worktree_path = PathBuf::from(worktree_path.trim()); - let refs_path = worktree_path.parent()?.parent()?.join("refs"); - Some(refs_path) } diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 1b317e1acbb24..9ca15aab09829 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -5241,7 +5241,7 @@ fn invalidate_path_on_cache_key() -> Result<()> { } #[test] -fn invalidate_path_on_commit() -> Result<()> { +fn invalidate_path_on_worktree_packed_commit() -> Result<()> { let context = uv_test::test_context!("3.12"); let requirements_txt = context.temp_dir.child("requirements.txt"); @@ -5265,19 +5265,22 @@ fn invalidate_path_on_commit() -> Result<()> { "#, )?; - // Create a Git repository. - context - .temp_dir + // Create a linked worktree with the branch reference in the common Git directory's + // `packed-refs` file. + let common_git_dir = context.temp_dir.child("common.git"); + let worktree_git_dir = common_git_dir.child("worktrees").child("editable"); + editable_dir .child(".git") + .write_str(&format!("gitdir: {}\n", worktree_git_dir.path().display()))?; + worktree_git_dir .child("HEAD") .write_str("ref: refs/heads/main")?; - context - .temp_dir - .child(".git") - .child("refs") - .child("heads") - .child("main") - .write_str("1b6638fdb424e993d8354e75c55a3e524050c857")?; + worktree_git_dir + .child("commondir") + .write_str(&format!("{}\n", common_git_dir.path().display()))?; + common_git_dir + .child("packed-refs") + .write_str("1b6638fdb424e993d8354e75c55a3e524050c857 refs/heads/main\n")?; uv_snapshot!(context.filters(), context.pip_install() .arg("-r") @@ -5311,13 +5314,9 @@ fn invalidate_path_on_commit() -> Result<()> { ); // Change the current commit. - context - .temp_dir - .child(".git") - .child("refs") - .child("heads") - .child("main") - .write_str("a1a42cbd10d83bafd8600ba81f72bbef6c579385")?; + common_git_dir + .child("packed-refs") + .write_str("a1a42cbd10d83bafd8600ba81f72bbef6c579385 refs/heads/main\n")?; // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install()