diff --git a/Cargo.lock b/Cargo.lock index 07445725a14..352882b0508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1315,6 +1315,7 @@ dependencies = [ "atoi", "bitflags", "bstr", + "document-features", "filetime", "git-bitmap", "git-features 0.19.1", @@ -1779,6 +1780,7 @@ name = "git-worktree" version = "0.0.0" dependencies = [ "bstr", + "document-features", "git-features 0.19.1", "git-hash 0.9.2", "git-index", @@ -1786,6 +1788,8 @@ dependencies = [ "git-odb 0.27.0", "git-testtools", "quick-error", + "serde", + "symlink", "tempfile", "walkdir", ] @@ -3007,6 +3011,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.86" diff --git a/Makefile b/Makefile index ab069ec3cbb..0b499addcac 100644 --- a/Makefile +++ b/Makefile @@ -89,6 +89,7 @@ check: ## Build all code in suitable configurations cd git-object && cargo check --all-features \ && cargo check --features verbose-object-parsing-errors cd git-index && cargo check --features serde1 + cd git-worktree && cargo check --features serde1 cd git-actor && cargo check --features serde1 cd git-pack && cargo check --features serde1 \ && cargo check --features pack-cache-lru-static \ diff --git a/README.md b/README.md index f30ad388b6a..6b8c324080b 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,10 @@ Follow linked crate name for detailed status. Please note that all crates follow * `gitoxide-core` * **very early** * [git-index](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-index) + * [git-worktree](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-worktree) * [git-bitmap](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-bitmap) * **idea** * [git-revision](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-revision) - * [git-worktree](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-worktree) * [git-tui](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-tui) * [git-bundle](https://github.com/Byron/gitoxide/blob/main/crate-status.md#git-bundle) diff --git a/git-index/Cargo.toml b/git-index/Cargo.toml index 35fbdbf69a3..44eac69f185 100644 --- a/git-index/Cargo.toml +++ b/git-index/Cargo.toml @@ -46,5 +46,11 @@ smallvec = "1.7.0" atoi = "1.0.0" bitflags = "1.3.2" +document-features = { version = "0.2.0", optional = true } + [dev-dependencies] git-testtools = { path = "../tests/tools"} + +[package.metadata.docs.rs] +features = ["document-features", "serde1"] + diff --git a/git-index/src/lib.rs b/git-index/src/lib.rs index 1f6ff98a791..8151618edc6 100644 --- a/git-index/src/lib.rs +++ b/git-index/src/lib.rs @@ -1,3 +1,8 @@ +//! ## Feature Flags +#![cfg_attr( + feature = "document-features", + cfg_attr(doc, doc = ::document_features::document_features!()) +)] #![deny(unsafe_code, missing_docs, rust_2018_idioms)] #![allow(missing_docs)] diff --git a/git-pack/src/data/output/entry/iter_from_counts.rs b/git-pack/src/data/output/entry/iter_from_counts.rs index 2071212cad8..d5f6176236a 100644 --- a/git-pack/src/data/output/entry/iter_from_counts.rs +++ b/git-pack/src/data/output/entry/iter_from_counts.rs @@ -56,7 +56,6 @@ where ); let (chunk_size, thread_limit, _) = parallel::optimize_chunk_size_and_thread_limit(chunk_size, Some(counts.len()), thread_limit, None); - let chunks = util::ChunkRanges::new(chunk_size, counts.len()); { let progress = Arc::new(parking_lot::Mutex::new(progress.add_child("resolving"))); progress.lock().init(None, git_features::progress::count("counts")); @@ -64,23 +63,13 @@ where let start = std::time::Instant::now(); parallel::in_parallel_if( || enough_counts_present, - chunks.clone(), + counts.chunks_mut(chunk_size), thread_limit, |_n| Vec::::new(), { let progress = Arc::clone(&progress); - let counts = &counts; let db = db.clone(); - move |chunk_range, buf| { - let chunk = { - let c = &counts[chunk_range]; - let mut_ptr = c.as_ptr() as *mut output::Count; - // SAFETY: We know that 'chunks' is only non-overlapping slices, and this function owns `counts`. - #[allow(unsafe_code)] - unsafe { - std::slice::from_raw_parts_mut(mut_ptr, c.len()) - } - }; + move |chunk, buf| { let chunk_size = chunk.len(); for count in chunk { use crate::data::output::count::PackLocation::*; @@ -135,8 +124,10 @@ where index } }; + let counts = Arc::new(counts); let progress = Arc::new(parking_lot::Mutex::new(progress)); + let chunks = util::ChunkRanges::new(chunk_size, counts.len()); parallel::reduce::Stepwise::new( chunks.enumerate(), diff --git a/git-ref/tests/packed/find.rs b/git-ref/tests/packed/find.rs index fc8f5f16045..ea965ebd782 100644 --- a/git-ref/tests/packed/find.rs +++ b/git-ref/tests/packed/find.rs @@ -175,7 +175,11 @@ fn find_speed() -> crate::Result { let packed = store.open_packed_buffer()?.expect("packed-refs present"); let start = std::time::Instant::now(); let mut num_refs = 0; - for r in packed.iter()?.take(10_000) { + #[cfg(windows)] + let count = 500; + #[cfg(not(windows))] + let count = 10_000; + for r in packed.iter()?.take(count) { num_refs += 1; let r = r?; assert_eq!( diff --git a/git-worktree/Cargo.toml b/git-worktree/Cargo.toml index b9dca5753e5..9f93704ff69 100644 --- a/git-worktree/Cargo.toml +++ b/git-worktree/Cargo.toml @@ -12,18 +12,30 @@ doctest = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +## Data structures implement `serde::Serialize` and `serde::Deserialize`. +serde1 = [ "serde", "bstr/serde1", "git-index/serde1", "git-hash/serde1", "git-object/serde1" ] + [dependencies] git-index = { version = "^0.1.0", path = "../git-index" } git-hash = { version = "^0.9.0", path = "../git-hash" } git-object = { version = "^0.17.0", path = "../git-object" } git-features = { version = "^0.19.1", path = "../git-features" } +serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"]} + quick-error = "2.0.1" bstr = { version = "0.2.13", default-features = false } +document-features = { version = "0.2.0", optional = true } +symlink = "0.1.0" + [dev-dependencies] git-testtools = { path = "../tests/tools" } git-odb = { path = "../git-odb" } walkdir = "2.3.2" tempfile = "3.2.0" + +[package.metadata.docs.rs] +features = ["document-features", "serde1"] diff --git a/git-worktree/src/fs.rs b/git-worktree/src/fs.rs new file mode 100644 index 00000000000..7c71001160b --- /dev/null +++ b/git-worktree/src/fs.rs @@ -0,0 +1,141 @@ +use std::path::Path; + +/// Common knowledge about the worktree that is needed across most interactions with the work tree +#[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] +#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)] +pub struct Capabilities { + /// If true, the filesystem will store paths as decomposed unicode, i.e. `ä` becomes `"a\u{308}"`, which means that + /// we have to turn these forms back from decomposed to precomposed unicode before storing it in the index or generally + /// using it. This also applies to input received from the command-line, so callers may have to be aware of this and + /// perform conversions accordingly. + /// If false, no conversions will be performed. + pub precompose_unicode: bool, + /// If true, the filesystem ignores the case of input, which makes `A` the same file as `a`. + /// This is also called case-folding. + pub ignore_case: bool, + /// If true, we assume the the executable bit is honored as part of the files mode. If false, we assume the file system + /// ignores the executable bit, hence it will be reported as 'off' even though we just tried to set it to be on. + pub executable_bit: bool, + /// If true, the file system supports symbolic links and we should try to create them. Otherwise symbolic links will be checked + /// out as files which contain the link as text. + pub symlink: bool, +} + +impl Capabilities { + /// try to determine all values in this context by probing them in the given `git_dir`, which + /// should be on the file system the git repository is located on. + /// `git_dir` is a typical git repository, expected to be populated with the typical files like `config`. + /// + /// All errors are ignored and interpreted on top of the default for the platform the binary is compiled for. + pub fn probe(git_dir: impl AsRef) -> Self { + let root = git_dir.as_ref(); + let ctx = Capabilities::default(); + Capabilities { + symlink: Self::probe_symlink(root).unwrap_or(ctx.symlink), + ignore_case: Self::probe_ignore_case(root).unwrap_or(ctx.ignore_case), + precompose_unicode: Self::probe_precompose_unicode(root).unwrap_or(ctx.precompose_unicode), + executable_bit: Self::probe_file_mode(root).unwrap_or(ctx.executable_bit), + } + } + + #[cfg(unix)] + fn probe_file_mode(root: &Path) -> std::io::Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + // test it exactly as we typically create executable files, not using chmod. + let test_path = root.join("_test_executable_bit"); + let res = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o777) + .open(&test_path) + .and_then(|f| f.metadata().map(|m| m.mode() & 0o100 == 0o100)); + std::fs::remove_file(test_path)?; + res + } + + #[cfg(not(unix))] + fn probe_file_mode(_root: &Path) -> std::io::Result { + Ok(false) + } + + fn probe_ignore_case(git_dir: &Path) -> std::io::Result { + std::fs::metadata(git_dir.join("cOnFiG")).map(|_| true).or_else(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + Ok(false) + } else { + Err(err) + } + }) + } + + fn probe_precompose_unicode(root: &Path) -> std::io::Result { + let precomposed = "ä"; + let decomposed = "a\u{308}"; + + let precomposed = root.join(precomposed); + std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&precomposed)?; + let res = root.join(decomposed).symlink_metadata().map(|_| true); + std::fs::remove_file(precomposed)?; + res + } + + fn probe_symlink(root: &Path) -> std::io::Result { + let src_path = root.join("__link_src_file"); + std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&src_path)?; + let link_path = root.join("__file_link"); + if symlink::symlink_file(&src_path, &link_path).is_err() { + std::fs::remove_file(&src_path)?; + return Ok(false); + } + + let res = std::fs::symlink_metadata(&link_path).map(|m| m.is_symlink()); + let cleanup = std::fs::remove_file(&src_path); + symlink::remove_symlink_file(&link_path) + .or_else(|_| std::fs::remove_file(&link_path)) + .and(cleanup)?; + res + } +} + +#[cfg(windows)] +impl Default for Capabilities { + fn default() -> Self { + Capabilities { + precompose_unicode: false, + ignore_case: true, + executable_bit: false, + symlink: false, + } + } +} + +#[cfg(target_os = "macos")] +impl Default for Capabilities { + fn default() -> Self { + Capabilities { + precompose_unicode: true, + ignore_case: true, + executable_bit: true, + symlink: true, + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +impl Default for Capabilities { + fn default() -> Self { + Capabilities { + precompose_unicode: false, + ignore_case: false, + executable_bit: true, + symlink: true, + } + } +} diff --git a/git-worktree/src/index.rs b/git-worktree/src/index.rs new file mode 100644 index 00000000000..d8afb59df46 --- /dev/null +++ b/git-worktree/src/index.rs @@ -0,0 +1,245 @@ +use git_hash::oid; + +use crate::{index, index::checkout::Collision}; + +pub mod checkout { + use bstr::BString; + use quick_error::quick_error; + + #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct Collision { + /// the path that collided with something already present on disk. + pub path: BString, + /// The io error we encountered when checking out `path`. + pub error_kind: std::io::ErrorKind, + } + + pub struct Outcome { + pub collisions: Vec, + } + + #[derive(Clone, Copy)] + pub struct Options { + /// capabilities of the file system + pub fs: crate::fs::Capabilities, + /// If true, we assume no file to exist in the target directory, and want exclusive access to it. + /// This should be enabled when cloning to avoid checks for freshness of files. This also enables + /// detection of collisions based on whether or not exclusive file creation succeeds or fails. + pub destination_is_initially_empty: bool, + /// If true, default false, try to checkout as much as possible and don't abort on first error which isn't + /// due to a conflict. + /// The operation will never fail, but count the encountered errors instead along with their paths. + pub keep_going: bool, + /// If true, a files creation time is taken into consideration when checking if a file changed. + /// Can be set to false in case other tools alter the creation time in ways that interfere with our operation. + /// + /// Default true. + pub trust_ctime: bool, + /// If true, all stat fields will be used when checking for up-to-date'ness of the entry. Otherwise + /// nano-second parts of mtime and ctime,uid, gid, inode and device number won't be used, leaving only + /// the whole-second part of ctime and mtime and the file size to be checked. + /// + /// Default true. + pub check_stat: bool, + } + + impl Default for Options { + fn default() -> Self { + Options { + fs: Default::default(), + destination_is_initially_empty: false, + keep_going: false, + trust_ctime: true, + check_stat: true, + } + } + } + + quick_error! { + #[derive(Debug)] + pub enum Error { + IllformedUtf8{ path: BString } { + display("Could not convert path to UTF8: {}", path) + } + Time(err: std::time::SystemTimeError) { + from() + source(err) + display("The clock was off when reading file related metadata after updating a file on disk") + } + Io(err: std::io::Error) { + from() + source(err) + display("IO error while writing blob or reading file metadata or changing filetype") + } + ObjectNotFound{ oid: git_hash::ObjectId, path: std::path::PathBuf } { + display("object {} for checkout at {} not found in object database", oid.to_hex(), path.display()) + } + } + } +} + +pub fn checkout( + index: &mut git_index::State, + path: impl AsRef, + mut find: Find, + options: checkout::Options, +) -> Result +where + Find: for<'a> FnMut(&oid, &'a mut Vec) -> Option>, +{ + use std::io::ErrorKind::AlreadyExists; + let root = path.as_ref(); + let mut buf = Vec::new(); + let mut collisions = Vec::new(); + for (entry, entry_path) in index.entries_mut_with_paths() { + // TODO: write test for that + if entry.flags.contains(git_index::entry::Flags::SKIP_WORKTREE) { + continue; + } + + let res = entry::checkout(entry, entry_path, &mut find, root, options, &mut buf); + match res { + Ok(()) => {} + // TODO: use ::IsDirectory as well when stabilized instead of raw_os_error() + #[cfg(windows)] + Err(index::checkout::Error::Io(err)) + if err.kind() == AlreadyExists || err.kind() == std::io::ErrorKind::PermissionDenied => + { + collisions.push(Collision { + path: entry_path.into(), + error_kind: err.kind(), + }); + } + #[cfg(not(windows))] + Err(index::checkout::Error::Io(err)) if err.kind() == AlreadyExists || err.raw_os_error() == Some(21) => { + // We are here because a file existed or was blocked by a directory which shouldn't be possible unless + // we are on a file insensitive file system. + collisions.push(Collision { + path: entry_path.into(), + error_kind: err.kind(), + }); + } + Err(err) => { + if options.keep_going { + todo!("keep going") + } else { + return Err(err); + } + } + } + } + Ok(checkout::Outcome { collisions }) +} + +pub(crate) mod entry { + use std::{ + convert::TryInto, + fs::{create_dir_all, OpenOptions}, + io::Write, + time::Duration, + }; + + use bstr::BStr; + use git_hash::oid; + use git_index::Entry; + + use crate::index; + + #[cfg_attr(not(unix), allow(unused_variables))] + pub fn checkout( + entry: &mut Entry, + entry_path: &BStr, + find: &mut Find, + root: &std::path::Path, + index::checkout::Options { + fs: + crate::fs::Capabilities { + symlink, + executable_bit, + .. + }, + destination_is_initially_empty, + .. + }: index::checkout::Options, + buf: &mut Vec, + ) -> Result<(), index::checkout::Error> + where + Find: for<'a> FnMut(&oid, &'a mut Vec) -> Option>, + { + let dest = root.join(git_features::path::from_byte_slice(entry_path).map_err(|_| { + index::checkout::Error::IllformedUtf8 { + path: entry_path.to_owned(), + } + })?); + create_dir_all(dest.parent().expect("entry paths are never empty"))?; // TODO: can this be avoided to create dirs when needed only? + + match entry.mode { + git_index::entry::Mode::FILE | git_index::entry::Mode::FILE_EXECUTABLE => { + let obj = find(&entry.id, buf).ok_or_else(|| index::checkout::Error::ObjectNotFound { + oid: entry.id, + path: root.to_path_buf(), + })?; + let mut options = OpenOptions::new(); + options + .create_new(destination_is_initially_empty) + .create(!destination_is_initially_empty) + .write(true); + #[cfg(unix)] + if executable_bit && entry.mode == git_index::entry::Mode::FILE_EXECUTABLE { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o777); + } + + let mut file = options.open(&dest)?; + file.write_all(obj.data)?; + // NOTE: we don't call `file.sync_all()` here knowing that some filesystems don't handle this well. + // revisit this once there is a bug to fix. + update_fstat(entry, file.metadata()?)?; + } + git_index::entry::Mode::SYMLINK => { + let obj = find(&entry.id, buf).ok_or_else(|| index::checkout::Error::ObjectNotFound { + oid: entry.id, + path: root.to_path_buf(), + })?; + let symlink_destination = git_features::path::from_byte_slice(obj.data) + .map_err(|_| index::checkout::Error::IllformedUtf8 { path: obj.data.into() })?; + + // TODO: how to deal with mode changes? Maybe this info can be passed once we check for whether + // a checkout is needed at all. + if symlink { + symlink::symlink_auto(symlink_destination, &dest)?; + } else { + std::fs::write(&dest, obj.data)?; + } + + update_fstat(entry, std::fs::symlink_metadata(&dest)?)?; + } + git_index::entry::Mode::DIR => todo!(), + git_index::entry::Mode::COMMIT => todo!(), + _ => unreachable!(), + } + Ok(()) + } + + fn update_fstat(entry: &mut Entry, meta: std::fs::Metadata) -> Result<(), index::checkout::Error> { + let ctime = meta + .created() + .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?; + let mtime = meta + .modified() + .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?; + + let stat = &mut entry.stat; + stat.mtime.secs = mtime + .as_secs() + .try_into() + .expect("by 2038 we found a solution for this"); + stat.mtime.nsecs = mtime.subsec_nanos(); + stat.ctime.secs = ctime + .as_secs() + .try_into() + .expect("by 2038 we found a solution for this"); + stat.ctime.nsecs = ctime.subsec_nanos(); + Ok(()) + } +} diff --git a/git-worktree/src/lib.rs b/git-worktree/src/lib.rs index 11901252e1b..e0bf42a64dd 100644 --- a/git-worktree/src/lib.rs +++ b/git-worktree/src/lib.rs @@ -1,175 +1,11 @@ +//! ## Feature Flags +#![cfg_attr( + feature = "document-features", + cfg_attr(doc, doc = ::document_features::document_features!()) +)] #![forbid(unsafe_code, rust_2018_idioms)] -pub mod index { - use git_hash::oid; +/// file system related utilities +pub mod fs; - pub mod checkout { - use bstr::BString; - use quick_error::quick_error; - - #[derive(Clone, Copy)] - pub struct Options { - pub symlinks: bool, - } - - impl Default for Options { - fn default() -> Self { - Options { symlinks: true } - } - } - - quick_error! { - #[derive(Debug)] - pub enum Error { - IllformedUtf8{ path: BString } { - display("Could not convert path to UTF8: {}", path) - } - Time(err: std::time::SystemTimeError) { - from() - source(err) - display("The clock was off when reading file related metadata after updating a file on disk") - } - Io(err: std::io::Error) { - from() - source(err) - display("IO error while writing blob or reading file metadata or changing filetype") - } - ObjectNotFound{ oid: git_hash::ObjectId, path: std::path::PathBuf } { - display("object {} for checkout at {} not found in object database", oid.to_hex(), path.display()) - } - } - } - } - - pub fn checkout( - index: &mut git_index::State, - path: impl AsRef, - mut find: Find, - options: checkout::Options, - ) -> Result<(), checkout::Error> - where - Find: for<'a> FnMut(&oid, &'a mut Vec) -> Option>, - { - let root = path.as_ref(); - let mut buf = Vec::new(); - for (entry, entry_path) in index.entries_mut_with_paths() { - // TODO: write test for that - if entry.flags.contains(git_index::entry::Flags::SKIP_WORKTREE) { - continue; - } - - entry::checkout(entry, entry_path, &mut find, root, options, &mut buf)?; - } - Ok(()) - } - - pub(crate) mod entry { - use std::{ - convert::TryInto, - fs::{create_dir_all, OpenOptions}, - io::Write, - time::Duration, - }; - - use bstr::BStr; - use git_hash::oid; - use git_index::Entry; - - use crate::index; - - pub fn checkout( - entry: &mut Entry, - entry_path: &BStr, - find: &mut Find, - root: &std::path::Path, - index::checkout::Options { symlinks }: index::checkout::Options, - buf: &mut Vec, - ) -> Result<(), index::checkout::Error> - where - Find: for<'a> FnMut(&oid, &'a mut Vec) -> Option>, - { - let dest = root.join(git_features::path::from_byte_slice(entry_path).map_err(|_| { - index::checkout::Error::IllformedUtf8 { - path: entry_path.to_owned(), - } - })?); - create_dir_all(dest.parent().expect("entry paths are never empty"))?; // TODO: can this be avoided to create dirs when needed only? - - match entry.mode { - git_index::entry::Mode::FILE | git_index::entry::Mode::FILE_EXECUTABLE => { - let obj = find(&entry.id, buf).ok_or_else(|| index::checkout::Error::ObjectNotFound { - oid: entry.id, - path: root.to_path_buf(), - })?; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - if entry.mode == git_index::entry::Mode::FILE_EXECUTABLE { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o777); - } - let mut file = options.open(&dest)?; - file.write_all(obj.data)?; - let met = file.metadata()?; - let ctime = met - .created() - .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?; - let mtime = met - .modified() - .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?; - - update_fstat(entry, ctime, mtime)?; - } - git_index::entry::Mode::SYMLINK => { - let obj = find(&entry.id, buf).ok_or_else(|| index::checkout::Error::ObjectNotFound { - oid: entry.id, - path: root.to_path_buf(), - })?; - let symlink_destination = git_features::path::from_byte_slice(obj.data) - .map_err(|_| index::checkout::Error::IllformedUtf8 { path: obj.data.into() })?; - if symlinks { - #[cfg(unix)] - std::os::unix::fs::symlink(symlink_destination, &dest)?; - #[cfg(windows)] - if dest.exists() { - if dest.is_file() { - std::os::windows::fs::symlink_file(symlink_destination, &dest)?; - } else { - std::os::windows::fs::symlink_dir(symlink_destination, &dest)?; - } - } - } else { - std::fs::write(&dest, obj.data)?; - } - let met = std::fs::symlink_metadata(&dest)?; - let ctime = met - .created() - .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?; - let mtime = met - .modified() - .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?; - update_fstat(entry, ctime, mtime)?; - } - git_index::entry::Mode::DIR => todo!(), - git_index::entry::Mode::COMMIT => todo!(), - _ => unreachable!(), - } - Ok(()) - } - - fn update_fstat(entry: &mut Entry, ctime: Duration, mtime: Duration) -> Result<(), index::checkout::Error> { - let stat = &mut entry.stat; - stat.mtime.secs = mtime - .as_secs() - .try_into() - .expect("by 2038 we found a solution for this"); - stat.mtime.nsecs = mtime.subsec_nanos(); - stat.ctime.secs = ctime - .as_secs() - .try_into() - .expect("by 2038 we found a solution for this"); - stat.ctime.nsecs = ctime.subsec_nanos(); - Ok(()) - } - } -} +pub mod index; diff --git a/git-worktree/tests/fixtures/make_ignorecase_collisions.sh b/git-worktree/tests/fixtures/make_ignorecase_collisions.sh new file mode 100644 index 00000000000..9fb772681e0 --- /dev/null +++ b/git-worktree/tests/fixtures/make_ignorecase_collisions.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -eu -o pipefail + +git init -q +git config commit.gpgsign false + +empty_oid=$(git hash-object -w --stdin = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.file_name().to_str() != Some("config")) + .map(|e| e.path()) + .collect(); + assert_eq!( + entries.len(), + 0, + "there should be no left-over files after probing, found {:?}", + entries + ); +} diff --git a/git-worktree/tests/index/mod.rs b/git-worktree/tests/index/mod.rs index a572dbe9ccc..35c51bc08a8 100644 --- a/git-worktree/tests/index/mod.rs +++ b/git-worktree/tests/index/mod.rs @@ -3,36 +3,132 @@ mod checkout { use std::os::unix::prelude::MetadataExt; use std::{ fs, + io::ErrorKind, path::{Path, PathBuf}, }; use git_object::bstr::ByteSlice; use git_odb::FindExt; - use git_worktree::index; + use git_worktree::{fs::Capabilities, index, index::checkout::Collision}; use tempfile::TempDir; use crate::fixture_path; + fn probe_gitoxide_dir() -> crate::Result { + Ok(git_worktree::fs::Capabilities::probe( + std::env::current_dir()?.join("..").join(".git"), + )) + } + + fn opts_with_symlink(symlink: bool) -> index::checkout::Options { + index::checkout::Options { + fs: git_worktree::fs::Capabilities { + symlink, + ..Default::default() + }, + destination_is_initially_empty: true, + ..Default::default() + } + } + + #[test] + fn symlinks_become_files_if_disabled() -> crate::Result { + let opts = opts_with_symlink(false); + let (source_tree, destination, _index, outcome) = + checkout_index_in_tmp_dir(opts, "make_mixed_without_submodules")?; + + assert_equality(&source_tree, &destination, opts.fs.symlink)?; + assert!(outcome.collisions.is_empty()); + + Ok(()) + } + #[test] fn allow_symlinks() -> crate::Result { - let opts = Default::default(); - let (source_tree, destination) = setup_fixture_with_options(opts)?; + let opts = opts_with_symlink(true); + if !probe_gitoxide_dir()?.symlink { + eprintln!("IGNORING symlink test on file system without symlink support"); + // skip if symlinks aren't supported anyway. + return Ok(()); + }; + let (source_tree, destination, _index, outcome) = + checkout_index_in_tmp_dir(opts, "make_mixed_without_submodules")?; - assert_equality(&source_tree, &destination, opts.symlinks)?; + assert_equality(&source_tree, &destination, opts.fs.symlink)?; + assert!(outcome.collisions.is_empty()); Ok(()) } #[test] - fn symlinks_become_files_if_disabled() -> crate::Result { - let opts = index::checkout::Options { symlinks: false }; - let (source_tree, destination) = setup_fixture_with_options(opts)?; + fn no_case_related_collisions_on_case_sensitive_filesystem() { + if probe_gitoxide_dir().unwrap().ignore_case { + eprintln!("Skipping case-sensitive testing on what would be a case-insenstive file system"); + return; + } + let opts = opts_with_symlink(true); + let (source_tree, destination, index, outcome) = + checkout_index_in_tmp_dir(opts, "make_ignorecase_collisions").unwrap(); - assert_equality(&source_tree, &destination, opts.symlinks)?; + let num_files = assert_equality(&source_tree, &destination, opts.fs.symlink).unwrap(); + assert_eq!(num_files, index.entries().len(), "it checks out all files"); + assert!(outcome.collisions.is_empty()); + } - Ok(()) + #[test] + fn collisions_are_detected_on_a_case_sensitive_filesystem() { + if !probe_gitoxide_dir().unwrap().ignore_case { + eprintln!("Skipping case-insensitive testing on what would be a case-senstive file system"); + return; + } + let opts = opts_with_symlink(true); + let (source_tree, destination, _index, outcome) = + checkout_index_in_tmp_dir(opts, "make_ignorecase_collisions").unwrap(); + + let error_kind = ErrorKind::AlreadyExists; + #[cfg(windows)] + let error_kind_dir = ErrorKind::PermissionDenied; + #[cfg(not(windows))] + let error_kind_dir = error_kind; + + assert_eq!( + outcome.collisions, + vec![ + Collision { + path: "FILE_x".into(), + error_kind, + }, + Collision { + path: "d".into(), + error_kind: error_kind_dir, + }, + Collision { + path: "file_X".into(), + error_kind, + }, + Collision { + path: "file_x".into(), + error_kind, + }, + ], + "these files couldn't be checked out" + ); + + let source_files = dir_structure(&source_tree); + assert_eq!( + stripped_prefix(&source_tree, &source_files), + vec![PathBuf::from("d"), PathBuf::from("file_x")], + "plenty of collisions prevent a checkout" + ); + + let dest_files = dir_structure(&destination); + assert_eq!( + stripped_prefix(&destination, &dest_files), + vec![PathBuf::from("D/B"), PathBuf::from("D/C"), PathBuf::from("FILE_X")], + "we checkout files in order and generally handle collision detection differently, hence the difference" + ); } - fn assert_equality(source_tree: &Path, destination: &TempDir, allow_symlinks: bool) -> crate::Result { + fn assert_equality(source_tree: &Path, destination: &TempDir, allow_symlinks: bool) -> crate::Result { let source_files = dir_structure(source_tree); let worktree_files = dir_structure(&destination); @@ -41,7 +137,9 @@ mod checkout { stripped_prefix(&destination, &worktree_files), ); + let mut count = 0; for (source_file, worktree_file) in source_files.iter().zip(worktree_files.iter()) { + count += 1; if !allow_symlinks && source_file.is_symlink() { assert!(!worktree_file.is_symlink()); assert_eq!(fs::read(worktree_file)?.to_path()?, fs::read_link(source_file)?); @@ -55,7 +153,7 @@ mod checkout { ); } } - Ok(()) + Ok(count) } pub fn dir_structure>(path: P) -> Vec { @@ -71,20 +169,28 @@ mod checkout { files } - fn setup_fixture_with_options(opts: git_worktree::index::checkout::Options) -> crate::Result<(PathBuf, TempDir)> { - let source_tree = fixture_path("make_repo"); + fn checkout_index_in_tmp_dir( + opts: index::checkout::Options, + name: &str, + ) -> crate::Result<( + PathBuf, + TempDir, + git_index::File, + git_worktree::index::checkout::Outcome, + )> { + let source_tree = fixture_path(name); let git_dir = source_tree.join(".git"); let mut index = git_index::File::at(git_dir.join("index"), Default::default())?; let odb = git_odb::at(git_dir.join("objects"))?; let destination = tempfile::tempdir()?; - index::checkout( + let outcome = index::checkout( &mut index, &destination, move |oid, buf| odb.find_blob(oid, buf).ok(), opts, )?; - Ok((source_tree, destination)) + Ok((source_tree, destination, index, outcome)) } fn stripped_prefix(prefix: impl AsRef, source_files: &[PathBuf]) -> Vec<&Path> { diff --git a/git-worktree/tests/worktree.rs b/git-worktree/tests/worktree.rs index 830e8787f43..ad199f619be 100644 --- a/git-worktree/tests/worktree.rs +++ b/git-worktree/tests/worktree.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +mod fs; mod index; type Result = std::result::Result>;