diff --git a/gix-discover/src/is.rs b/gix-discover/src/is.rs index 57dcda9338a..a09c7d9ec40 100644 --- a/gix-discover/src/is.rs +++ b/gix-discover/src/is.rs @@ -106,7 +106,7 @@ pub(crate) fn git_with_metadata( let worktree_and_common_dir = crate::path::from_plain_file(&common_dir) .and_then(Result::ok) .and_then(|cd| { - crate::path::from_plain_file(&dot_git.join("gitdir")) + crate::path::from_plain_file_relative_to_file(&dot_git.join("gitdir")) .and_then(Result::ok) .map(|worktree_gitfile| (crate::path::without_dot_git_dir(worktree_gitfile), cd)) }); diff --git a/gix-discover/src/path.rs b/gix-discover/src/path.rs index e783c656a40..674bacfc324 100644 --- a/gix-discover/src/path.rs +++ b/gix-discover/src/path.rs @@ -44,6 +44,30 @@ fn read_regular_file_content_with_size_limit(path: &std::path::Path) -> std::io: Ok(buf) } +/// Read a plain path file, returning `None` if the file is missing. +/// +/// Linked-worktree `gitdir` files are plain path files in Git, not `gitdir:` +/// files. Match Git's `get_linked_worktree()` behavior by trimming trailing +/// whitespace before interpreting the content. Empty or whitespace-only path +/// files are invalid. +fn read_plain_file_content(path: &std::path::Path) -> Option>> { + use bstr::ByteSlice; + let mut buf = match read_regular_file_content_with_size_limit(path) { + Ok(buf) => buf, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => return Some(Err(err)), + }; + let trimmed_len = buf.trim_end().len(); + buf.truncate(trimmed_len); + if buf.is_empty() { + return Some(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Refusing to read an empty path from '{}'", path.display()), + ))); + } + Some(Ok(buf)) +} + /// Guess the kind of repository by looking at its `git_dir` path and return it. /// Return `None` if `git_dir` isn't called `.git` or isn't within `.git/worktrees` or `.git/modules`, or if it's /// a `.git` suffix like in `foo.git`. @@ -74,15 +98,21 @@ pub fn repository_kind(git_dir: &Path) -> Option { /// Reads a plain path from a file that contains it as its only content, with trailing newlines trimmed. pub fn from_plain_file(path: &std::path::Path) -> Option> { - use bstr::ByteSlice; - let mut buf = match read_regular_file_content_with_size_limit(path) { - Ok(buf) => buf, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, - Err(err) => return Some(Err(err)), - }; - let trimmed_len = buf.trim_end().len(); - buf.truncate(trimmed_len); - Some(Ok(gix_path::from_bstring(buf))) + read_plain_file_content(path).map(|res| res.map(gix_path::from_bstring)) +} + +/// Reads a plain path from a file like [`from_plain_file()`], resolving relative paths against +/// the file's containing directory as needed. +pub fn from_plain_file_relative_to_file(path: &std::path::Path) -> Option> { + read_plain_file_content(path).map(|res| { + res.map(|buf| { + let plain_path = gix_path::from_bstring(buf); + match (plain_path.is_relative(), path.parent()) { + (true, Some(parent)) => parent.join(plain_path), + _ => plain_path, + } + }) + }) } /// Reads typical `gitdir: ` files from disk as used by worktrees and submodules. diff --git a/gix-discover/tests/discover/path.rs b/gix-discover/tests/discover/path.rs index 1f48a7e24ab..8412abc2f07 100644 --- a/gix-discover/tests/discover/path.rs +++ b/gix-discover/tests/discover/path.rs @@ -44,6 +44,70 @@ mod from_git_dir_file { } } +mod from_plain_file_relative_to_file { + use crate::path::plain_file_with_content; + use std::path::{Path, PathBuf}; + + #[test] + fn relative_path_is_made_absolute_relative_to_containing_dir() -> crate::Result { + let (path, plain_file) = write_and_read(b"relative/path\n")?; + assert_eq!(path, plain_file.parent().unwrap().join(Path::new("relative/path"))); + Ok(()) + } + + #[test] + fn empty_or_whitespace_only_path_is_invalid() -> crate::Result { + for content in [b"".as_slice(), b" \n".as_slice()] { + let file = plain_file_with_content(content)?; + let err = gix_discover::path::from_plain_file_relative_to_file(file.path()) + .expect("file exists") + .expect_err("empty paths must be rejected"); + assert_eq!( + err.kind(), + std::io::ErrorKind::InvalidData, + "empty plain path files are malformed, just like in Git" + ); + } + Ok(()) + } + + fn write_and_read(content: &[u8]) -> crate::Result<(PathBuf, PathBuf)> { + let file = plain_file_with_content(content)?; + Ok(( + gix_discover::path::from_plain_file_relative_to_file(file.path()) + .expect("file exists") + .expect("valid plain path"), + file.path().into(), + )) + } +} + +mod from_plain_file { + use crate::path::plain_file_with_content; + + #[test] + fn empty_or_whitespace_only_path_is_invalid() -> crate::Result { + for content in [b"".as_slice(), b" \n".as_slice()] { + let file = plain_file_with_content(content)?; + let err = gix_discover::path::from_plain_file(file.path()) + .expect("file exists") + .expect_err("empty paths must be rejected"); + assert_eq!( + err.kind(), + std::io::ErrorKind::InvalidData, + "empty plain path files are malformed, just like in Git" + ); + } + Ok(()) + } +} + +fn plain_file_with_content(content: &[u8]) -> std::io::Result { + let mut file = tempfile::NamedTempFile::new()?; + std::io::Write::write_all(&mut file, content)?; + Ok(file) +} + #[test] fn repository_kind() { use gix_discover::path::{RepositoryKind::*, repository_kind}; diff --git a/gix-discover/tests/discover/upwards/mod.rs b/gix-discover/tests/discover/upwards/mod.rs index f5583d2cfe3..5ad0623ce7f 100644 --- a/gix-discover/tests/discover/upwards/mod.rs +++ b/gix-discover/tests/discover/upwards/mod.rs @@ -235,6 +235,66 @@ fn from_existing_worktree() -> crate::Result { Ok(()) } +#[test] +fn from_existing_worktree_with_relative_linking_files() -> crate::Result { + let fixture = gix_testtools::scripted_fixture_read_only_needs_archive("make_worktree_relative_linking.sh")?; + let main = fixture.join("main"); + let linked = fixture.join("linked"); + let private_git_dir = main.join(".git/worktrees/linked"); + assert_eq!( + std::fs::read_to_string(linked.join(".git"))?, + "gitdir: ../main/.git/worktrees/linked\n", + "the linked checkout uses a relative gitdir file" + ); + let backlink = std::fs::read_to_string(private_git_dir.join("gitdir"))?; + assert_eq!( + backlink, "../../../../linked/.git\n", + "the private git dir points back to the checkout with a relative path" + ); + + for discover_path in [&linked, &private_git_dir] { + let (path, trust) = gix_discover::upwards(discover_path)?; + assert_eq!(trust, expected_trust()); + let (actual_git_dir, actual_worktree) = path.into_repository_and_work_tree_directories(); + assert_eq!( + gix_path::realpath(&actual_git_dir)?, + gix_path::realpath(&private_git_dir)?, + "discovery resolves the private git dir from relative worktree metadata" + ); + assert_eq!( + actual_worktree.as_deref().map(gix_path::realpath).transpose()?, + Some(gix_path::realpath(&linked)?), + "discovery resolves the linked worktree from relative worktree metadata" + ); + } + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn from_symlinked_worktree_with_relative_linking_files() -> crate::Result { + let fixture = gix_testtools::scripted_fixture_read_only_needs_archive("make_worktree_relative_linking.sh")?; + let main = fixture.join("actual/main"); + let linked_symlink = fixture.join("linked-symlink"); + + let (path, trust) = gix_discover::upwards(&linked_symlink)?; + assert_eq!(trust, expected_trust()); + let (actual_git_dir, actual_worktree) = path.into_repository_and_work_tree_directories(); + assert_eq!( + gix_path::realpath(&actual_git_dir)?, + gix_path::realpath(main.join(".git/worktrees/linked"))?, + "the private git dir is found through a relative gitdir file reached via a symlinked checkout" + ); + assert_eq!( + actual_worktree.as_deref(), + Some(linked_symlink.as_path()), + "the discovered worktree remains the user-provided symlinked checkout" + ); + + Ok(()) +} + #[cfg(target_os = "macos")] #[test] fn cross_fs() -> crate::Result { diff --git a/gix-discover/tests/fixtures/generated-archives/make_worktree_relative_linking.tar b/gix-discover/tests/fixtures/generated-archives/make_worktree_relative_linking.tar new file mode 100644 index 00000000000..5a11d963e52 Binary files /dev/null and b/gix-discover/tests/fixtures/generated-archives/make_worktree_relative_linking.tar differ diff --git a/gix-discover/tests/fixtures/make_worktree_relative_linking.sh b/gix-discover/tests/fixtures/make_worktree_relative_linking.sh new file mode 100755 index 00000000000..3ce37247680 --- /dev/null +++ b/gix-discover/tests/fixtures/make_worktree_relative_linking.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +make_repo_with_relative_worktree_links() { + local base=$1 + + mkdir -p "$base" + git -C "$base" init -q main + ( + cd "$base/main" + git commit -q --allow-empty -m init + git worktree add -q --detach --relative-paths ../linked HEAD + ) +} + +make_repo_with_relative_worktree_links . +make_repo_with_relative_worktree_links actual + +# The symlinked checkout variant is only used by Unix tests. On platforms where +# creating symlinks is unavailable, keep the rest of the fixture useful. +ln -s actual/linked linked-symlink 2>/dev/null || true diff --git a/gix/src/worktree/proxy.rs b/gix/src/worktree/proxy.rs index 222fb2685d9..dd7cd856d0c 100644 --- a/gix/src/worktree/proxy.rs +++ b/gix/src/worktree/proxy.rs @@ -47,7 +47,7 @@ impl Proxy<'_> { /// Note that the location might not exist. pub fn base(&self) -> std::io::Result { let git_dir = self.git_dir.join("gitdir"); - let base_dot_git = gix_discover::path::from_plain_file(&git_dir).ok_or_else(|| { + let base_dot_git = gix_discover::path::from_plain_file_relative_to_file(&git_dir).ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, format!("Required file '{}' does not exist", git_dir.display()), diff --git a/gix/tests/fixtures/generated-archives/make_worktree_relative_linking.tar b/gix/tests/fixtures/generated-archives/make_worktree_relative_linking.tar new file mode 100644 index 00000000000..3a4ed0d4596 Binary files /dev/null and b/gix/tests/fixtures/generated-archives/make_worktree_relative_linking.tar differ diff --git a/gix/tests/fixtures/make_worktree_relative_linking.sh b/gix/tests/fixtures/make_worktree_relative_linking.sh new file mode 100755 index 00000000000..74c07ec801d --- /dev/null +++ b/gix/tests/fixtures/make_worktree_relative_linking.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +make_repo_with_relative_worktree_links() { + local base=$1 + + mkdir -p "$base" + git -C "$base" init -q main + ( + cd "$base/main" + git commit -q --allow-empty -m init + git worktree add -q --detach --relative-paths ../linked HEAD + ) +} + +make_repo_with_relative_worktree_links . +make_repo_with_relative_worktree_links actual + +# The symlinked main-repository variant is only used by Unix tests. On platforms +# where creating symlinks is unavailable, keep the rest of the fixture useful. +ln -s actual/main main-symlink 2>/dev/null || true diff --git a/gix/tests/gix/repository/worktree.rs b/gix/tests/gix/repository/worktree.rs index 94624f73aa1..10554cdc2f8 100644 --- a/gix/tests/gix/repository/worktree.rs +++ b/gix/tests/gix/repository/worktree.rs @@ -261,6 +261,58 @@ fn from_nonbare_parent_repo() { run_assertions(repo, false /* bare */); } +#[test] +fn linked_worktree_proxy_base_with_relative_linking_files() -> crate::Result { + let fixture = gix_testtools::scripted_fixture_read_only_needs_archive("make_worktree_relative_linking.sh")?; + let main = fixture.join("main"); + let linked = fixture.join("linked"); + let private_git_dir = main.join(".git/worktrees/linked"); + let repo = gix::open(&main)?; + let worktrees = repo.worktrees()?; + assert_eq!(worktrees.len(), 1, "the relative-path fixture has one linked worktree"); + let proxy = worktrees.into_iter().next().expect("one worktree"); + + assert_eq!( + gix_path::realpath(proxy.base()?)?, + gix_path::realpath(&linked)?, + "proxy bases resolve relative worktrees//gitdir paths against the private git dir" + ); + let linked_repo = proxy.into_repo()?; + assert_eq!( + linked_repo.workdir().map(gix_path::realpath).transpose()?, + Some(gix_path::realpath(&linked)?) + ); + assert_eq!(linked_repo.git_dir(), private_git_dir); + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn linked_worktree_proxy_base_with_symlinked_main_repo() -> crate::Result { + let fixture = gix_testtools::scripted_fixture_read_only_needs_archive("make_worktree_relative_linking.sh")?; + let linked = fixture.join("actual/linked"); + let main_symlink = fixture.join("main-symlink"); + + let repo = gix::open(&main_symlink)?; + let worktrees = repo.worktrees()?; + assert_eq!(worktrees.len(), 1, "the relative-path fixture has one linked worktree"); + let proxy = worktrees.into_iter().next().expect("one worktree"); + + assert_eq!( + gix_path::realpath(proxy.base()?)?, + gix_path::realpath(&linked)?, + "proxy bases preserve symlink semantics when resolving relative worktrees//gitdir paths" + ); + let repo = proxy.into_repo()?; + assert_eq!( + repo.workdir().map(gix_path::realpath).transpose()?, + Some(gix_path::realpath(&linked)?) + ); + + Ok(()) +} + #[test] fn from_nonbare_parent_repo_set_workdir() -> gix_testtools::Result { if gix_testtools::should_skip_as_git_version_is_smaller_than(2, 31, 0) {