`gix-worktree` is now a base-crate for use by derived crates that provide additional functionality. Shared types or utilities go here.
Unified · Split
| @@ -2466,10 +2466,8 @@ version = "0.24.0" · 2465 unmodified lines | |||
| 2466 | dependencies = [ | 2466 | dependencies = [ |
| 2467 | "bstr", | 2467 | "bstr", |
| 2468 | "document-features", | 2468 | "document-features", |
| 2469 | "filetime", | ||
| 2470 | "gix-attributes 0.16.0", | 2469 | "gix-attributes 0.16.0", |
| 2471 | "gix-features 0.32.1", | 2470 | "gix-features 0.32.1", |
| 2472 | "gix-filter", | ||
| 2473 | "gix-fs 0.4.1", | 2471 | "gix-fs 0.4.1", |
| 2474 | "gix-glob 0.10.2", | 2472 | "gix-glob 0.10.2", |
| 2475 | "gix-hash 0.11.4", | 2473 | "gix-hash 0.11.4", |
| @@ -2479,13 +2477,8 @@ dependencies = [ · 3 unmodified lines | |||
| 2479 | "gix-odb", | 2477 | "gix-odb", |
| 2480 | "gix-path 0.8.4", | 2478 | "gix-path 0.8.4", |
| 2481 | "gix-testtools", | 2479 | "gix-testtools", |
| 2482 | "io-close", | ||
| 2483 | "once_cell", | ||
| 2484 | "serde", | 2480 | "serde", |
| 2485 | "symlink", | 2481 | "symlink", |
| 2486 | "tempfile", | ||
| 2487 | "thiserror", | ||
| 2488 | "walkdir", | ||
| 2489 | ] | 2482 | ] |
| 2490 | 2483 | ||
| 2491 | [[package]] | 2484 | [[package]] |
| @@ -482,25 +482,7 @@ Make it the best-performing implementation and the most convenient one. · 481 unmodified lines | |||
| 482 | * [x] attributes | 482 | * [x] attributes |
| 483 | 483 | ||
| 484 | ### gix-worktree | 484 | ### gix-worktree |
| 485 | * handle the working **tree/checkout** | 485 | * [x] A stack to to efficiently generate attribute lists for matching paths against. |
| 486 | - [x] checkout an index of files, executables and symlinks just as fast as git | ||
| 487 | - [x] forbid symlinks in directories | ||
| 488 | - [ ] handle submodules | ||
| 489 | - [ ] handle sparse directories | ||
| 490 | - [ ] handle sparse index | ||
| 491 | - [x] linear scaling with multi-threading up to IO saturation | ||
| 492 | - supported attributes to affect working tree and index contents | ||
| 493 | - [x] eol | ||
| 494 | - [x] working-tree-encoding | ||
| 495 | - …more | ||
| 496 | - **filtering** | ||
| 497 | - [x] `text` | ||
| 498 | - [x] `ident` | ||
| 499 | - [x] filter processes | ||
| 500 | - [x] single-invocation clean/smudge filters | ||
| 501 | * access to per-path information, like `.gitignore` and `.gitattributes` in a manner well suited for efficient lookups | ||
| 502 | * [x] _exclude_ information | ||
| 503 | * [x] attributes | ||
| 504 | 486 | ||
| 505 | ### gix-revision | 487 | ### gix-revision |
| 506 | * [x] `describe()` (similar to `git name-rev`) | 488 | * [x] `describe()` (similar to `git name-rev`) |
| @@ -3,7 +3,7 @@ name = "gix-worktree" · 2 unmodified lines | |||
| 3 | version = "0.24.0" | 3 | version = "0.24.0" |
| 4 | repository = "https://github.com/Byron/gitoxide" | 4 | repository = "https://github.com/Byron/gitoxide" |
| 5 | license = "MIT OR Apache-2.0" | 5 | license = "MIT OR Apache-2.0" |
| 6 | description = "A crate of the gitoxide project dedicated implementing everything around working trees and git excludes" | 6 | description = "A crate of the gitoxide project for shared worktree related types and utilities." |
| 7 | authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"] | 7 | authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"] |
| 8 | edition = "2021" | 8 | edition = "2021" |
| 9 | include = ["src/**/*", "LICENSE-*", "CHANGELOG.md"] | 9 | include = ["src/**/*", "LICENSE-*", "CHANGELOG.md"] |
| @@ -33,25 +33,17 @@ gix-path = { version = "^0.8.4", path = "../gix-path" } · 23 unmodified lines | |||
| 33 | gix-attributes = { version = "^0.16.0", path = "../gix-attributes" } | 33 | gix-attributes = { version = "^0.16.0", path = "../gix-attributes" } |
| 34 | gix-ignore = { version = "^0.5.1", path = "../gix-ignore" } | 34 | gix-ignore = { version = "^0.5.1", path = "../gix-ignore" } |
| 35 | gix-features = { version = "^0.32.1", path = "../gix-features" } | 35 | gix-features = { version = "^0.32.1", path = "../gix-features" } |
| 36 | gix-filter = { version = "^0.3.0", path = "../gix-filter" } | ||
| 37 | 36 | ||
| 38 | serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"]} | 37 | serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"]} |
| 39 | 38 | ||
| 40 | thiserror = "1.0.26" | ||
| 41 | filetime = "0.2.15" | ||
| 42 | bstr = { version = "1.3.0", default-features = false } | 39 | bstr = { version = "1.3.0", default-features = false } |
| 43 | 40 | ||
| 44 | document-features = { version = "0.2.0", optional = true } | 41 | document-features = { version = "0.2.0", optional = true } |
| 45 | io-close = "0.3.7" | ||
| 46 | 42 | ||
| 47 | [dev-dependencies] | 43 | [dev-dependencies] |
| 48 | gix-testtools = { path = "../tests/tools" } | 44 | gix-testtools = { path = "../tests/tools" } |
| 49 | gix-odb = { path = "../gix-odb" } | 45 | gix-odb = { path = "../gix-odb" } |
| 50 | symlink = "0.1.0" | 46 | symlink = "0.1.0" |
| 51 | once_cell = "1.18.0" | ||
| 52 | |||
| 53 | walkdir = "2.3.2" | ||
| 54 | tempfile = "3.2.0" | ||
| 55 | 47 | ||
| 56 | [package.metadata.docs.rs] | 48 | [package.metadata.docs.rs] |
| 57 | features = ["document-features", "serde"] | 49 | features = ["document-features", "serde"] |
| @@ -1,402 +0,0 @@ | |||
| 1 | use std::{ | ||
| 2 | collections::BTreeSet, | ||
| 3 | sync::atomic::{AtomicUsize, Ordering}, | ||
| 4 | }; | ||
| 5 | |||
| 6 | use bstr::{BStr, BString}; | ||
| 7 | use gix_hash::oid; | ||
| 8 | |||
| 9 | use crate::{checkout, checkout::entry, Cache}; | ||
| 10 | |||
| 11 | mod reduce { | ||
| 12 | use std::marker::PhantomData; | ||
| 13 | |||
| 14 | use gix_features::progress::Progress; | ||
| 15 | |||
| 16 | use crate::checkout; | ||
| 17 | |||
| 18 | pub struct Reduce<'a, 'entry, P1, P2, E> { | ||
| 19 | pub files: Option<&'a mut P1>, | ||
| 20 | pub bytes: Option<&'a mut P2>, | ||
| 21 | pub aggregate: super::Outcome<'entry>, | ||
| 22 | pub marker: PhantomData<E>, | ||
| 23 | } | ||
| 24 | |||
| 25 | impl<'a, 'entry, P1, P2, E> gix_features::parallel::Reduce for Reduce<'a, 'entry, P1, P2, E> | ||
| 26 | where | ||
| 27 | P1: Progress, | ||
| 28 | P2: Progress, | ||
| 29 | E: std::error::Error + Send + Sync + 'static, | ||
| 30 | { | ||
| 31 | type Input = Result<super::Outcome<'entry>, checkout::Error<E>>; | ||
| 32 | type FeedProduce = (); | ||
| 33 | type Output = super::Outcome<'entry>; | ||
| 34 | type Error = checkout::Error<E>; | ||
| 35 | |||
| 36 | fn feed(&mut self, item: Self::Input) -> Result<Self::FeedProduce, Self::Error> { | ||
| 37 | let item = item?; | ||
| 38 | let super::Outcome { | ||
| 39 | bytes_written, | ||
| 40 | files, | ||
| 41 | delayed_symlinks, | ||
| 42 | errors, | ||
| 43 | collisions, | ||
| 44 | delayed_paths_unknown, | ||
| 45 | delayed_paths_unprocessed, | ||
| 46 | } = item; | ||
| 47 | self.aggregate.bytes_written += bytes_written; | ||
| 48 | self.aggregate.files += files; | ||
| 49 | self.aggregate.delayed_symlinks.extend(delayed_symlinks); | ||
| 50 | self.aggregate.errors.extend(errors); | ||
| 51 | self.aggregate.collisions.extend(collisions); | ||
| 52 | self.aggregate.delayed_paths_unknown.extend(delayed_paths_unknown); | ||
| 53 | self.aggregate | ||
| 54 | .delayed_paths_unprocessed | ||
| 55 | .extend(delayed_paths_unprocessed); | ||
| 56 | |||
| 57 | if let Some(progress) = self.bytes.as_deref_mut() { | ||
| 58 | progress.set(self.aggregate.bytes_written as gix_features::progress::Step); | ||
| 59 | } | ||
| 60 | if let Some(progress) = self.files.as_deref_mut() { | ||
| 61 | progress.set(self.aggregate.files); | ||
| 62 | } | ||
| 63 | |||
| 64 | Ok(()) | ||
| 65 | } | ||
| 66 | |||
| 67 | fn finalize(self) -> Result<Self::Output, Self::Error> { | ||
| 68 | Ok(self.aggregate) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | pub use reduce::Reduce; | ||
| 73 | |||
| 74 | use crate::checkout::entry::DelayedFilteredStream; | ||
| 75 | |||
| 76 | #[derive(Default)] | ||
| 77 | pub struct Outcome<'a> { | ||
| 78 | pub collisions: Vec<checkout::Collision>, | ||
| 79 | pub errors: Vec<checkout::ErrorRecord>, | ||
| 80 | pub delayed_symlinks: Vec<(&'a mut gix_index::Entry, &'a BStr)>, | ||
| 81 | // all (immediately) written bytes | ||
| 82 | pub bytes_written: u64, | ||
| 83 | // the amount of files we processed | ||
| 84 | pub files: usize, | ||
| 85 | /// Relative paths that the process listed as 'delayed' even though we never passed them. | ||
| 86 | pub delayed_paths_unknown: Vec<BString>, | ||
| 87 | /// All paths that were left unprocessed, because they were never listed by the process even though we passed them. | ||
| 88 | pub delayed_paths_unprocessed: Vec<BString>, | ||
| 89 | } | ||
| 90 | |||
| 91 | #[derive(Clone)] | ||
| 92 | pub struct Context<Find: Clone> { | ||
| 93 | pub find: Find, | ||
| 94 | pub path_cache: Cache, | ||
| 95 | pub filters: gix_filter::Pipeline, | ||
| 96 | pub buf: Vec<u8>, | ||
| 97 | pub options: Options, | ||
| 98 | } | ||
| 99 | |||
| 100 | #[derive(Clone, Copy)] | ||
| 101 | pub struct Options { | ||
| 102 | pub fs: gix_fs::Capabilities, | ||
| 103 | pub destination_is_initially_empty: bool, | ||
| 104 | pub overwrite_existing: bool, | ||
| 105 | pub keep_going: bool, | ||
| 106 | pub filter_process_delay: gix_filter::driver::apply::Delay, | ||
| 107 | } | ||
| 108 | |||
| 109 | impl From<&checkout::Options> for Options { | ||
| 110 | fn from(opts: &checkout::Options) -> Self { | ||
| 111 | Options { | ||
| 112 | fs: opts.fs, | ||
| 113 | destination_is_initially_empty: opts.destination_is_initially_empty, | ||
| 114 | overwrite_existing: opts.overwrite_existing, | ||
| 115 | keep_going: opts.keep_going, | ||
| 116 | filter_process_delay: opts.filter_process_delay, | ||
| 117 | } | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | pub fn process<'entry, Find, E>( | ||
| 122 | entries_with_paths: impl Iterator<Item = (&'entry mut gix_index::Entry, &'entry BStr)>, | ||
| 123 | files: Option<&AtomicUsize>, | ||
| 124 | bytes: Option<&AtomicUsize>, | ||
| 125 | delayed_filter_results: &mut Vec<DelayedFilteredStream<'entry>>, | ||
| 126 | ctx: &mut Context<Find>, | ||
| 127 | ) -> Result<Outcome<'entry>, checkout::Error<E>> | ||
| 128 | where | ||
| 129 | Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Clone, | ||
| 130 | E: std::error::Error + Send + Sync + 'static, | ||
| 131 | { | ||
| 132 | let mut delayed_symlinks = Vec::new(); | ||
| 133 | let mut collisions = Vec::new(); | ||
| 134 | let mut errors = Vec::new(); | ||
| 135 | let mut bytes_written = 0; | ||
| 136 | let mut files_in_chunk = 0; | ||
| 137 | |||
| 138 | for (entry, entry_path) in entries_with_paths { | ||
| 139 | // TODO: write test for that | ||
| 140 | if entry.flags.contains(gix_index::entry::Flags::SKIP_WORKTREE) { | ||
| 141 | if let Some(files) = files { | ||
| 142 | files.fetch_add(1, Ordering::SeqCst); | ||
| 143 | } | ||
| 144 | files_in_chunk += 1; | ||
| 145 | continue; | ||
| 146 | } | ||
| 147 | |||
| 148 | // Symlinks always have to be delayed on windows as they have to point to something that exists on creation. | ||
| 149 | // And even if not, there is a distinction between file and directory symlinks, hence we have to check what the target is | ||
| 150 | // before creating it. | ||
| 151 | // And to keep things sane, we just do the same on non-windows as well which is similar to what git does and adds some safety | ||
| 152 | // around writing through symlinks (even though we handle this). | ||
| 153 | // This also means that we prefer content in files over symlinks in case of collisions, which probably is for the better, too. | ||
| 154 | if entry.mode == gix_index::entry::Mode::SYMLINK { | ||
| 155 | delayed_symlinks.push((entry, entry_path)); | ||
| 156 | continue; | ||
| 157 | } | ||
| 158 | |||
| 159 | match checkout_entry_handle_result(entry, entry_path, &mut errors, &mut collisions, files, bytes, ctx)? { | ||
| 160 | entry::Outcome::Written { bytes } => { | ||
| 161 | bytes_written += bytes as u64; | ||
| 162 | files_in_chunk += 1 | ||
| 163 | } | ||
| 164 | entry::Outcome::Delayed(delayed) => delayed_filter_results.push(delayed), | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | Ok(Outcome { | ||
| 169 | bytes_written, | ||
| 170 | files: files_in_chunk, | ||
| 171 | errors, | ||
| 172 | collisions, | ||
| 173 | delayed_symlinks, | ||
| 174 | delayed_paths_unknown: Vec::new(), | ||
| 175 | delayed_paths_unprocessed: Vec::new(), | ||
| 176 | }) | ||
| 177 | } | ||
| 178 | |||
| 179 | pub fn process_delayed_filter_results<Find, E>( | ||
| 180 | mut delayed_filter_results: Vec<DelayedFilteredStream<'_>>, | ||
| 181 | files: Option<&AtomicUsize>, | ||
| 182 | bytes: Option<&AtomicUsize>, | ||
| 183 | out: &mut Outcome<'_>, | ||
| 184 | ctx: &mut Context<Find>, | ||
| 185 | ) -> Result<(), checkout::Error<E>> | ||
| 186 | where | ||
| 187 | Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Clone, | ||
| 188 | E: std::error::Error + Send + Sync + 'static, | ||
| 189 | { | ||
| 190 | let Options { | ||
| 191 | destination_is_initially_empty, | ||
| 192 | overwrite_existing, | ||
| 193 | keep_going, | ||
| 194 | .. | ||
| 195 | } = ctx.options; | ||
| 196 | let mut bytes_written = 0; | ||
| 197 | let mut delayed_files = 0; | ||
| 198 | // Sort by path for fast lookups | ||
| 199 | delayed_filter_results.sort_by(|a, b| a.entry_path.cmp(b.entry_path)); | ||
| 200 | // We process each key and do as the filter process tells us, while collecting data about the overall progress. | ||
| 201 | let keys: BTreeSet<_> = delayed_filter_results.iter().map(|d| d.key.clone()).collect(); | ||
| 202 | let mut unknown_paths = Vec::new(); | ||
| 203 | let mut rela_path_as_path = Default::default(); | ||
| 204 | for key in keys { | ||
| 205 | loop { | ||
| 206 | let rela_paths = ctx.filters.driver_state_mut().list_delayed_paths(&key)?; | ||
| 207 | if rela_paths.is_empty() { | ||
| 208 | break; | ||
| 209 | } | ||
| 210 | |||
| 211 | for rela_path in rela_paths { | ||
| 212 | let delayed = match delayed_filter_results.binary_search_by(|d| d.entry_path.cmp(rela_path.as_ref())) { | ||
| 213 | Ok(idx) => &mut delayed_filter_results[idx], | ||
| 214 | Err(_) => { | ||
| 215 | if keep_going { | ||
| 216 | unknown_paths.push(rela_path); | ||
| 217 | continue; | ||
| 218 | } else { | ||
| 219 | return Err(checkout::Error::FilterPathUnknown { rela_path }); | ||
| 220 | } | ||
| 221 | } | ||
| 222 | }; | ||
| 223 | let mut read = std::io::BufReader::with_capacity( | ||
| 224 | 512 * 1024, | ||
| 225 | ctx.filters.driver_state_mut().fetch_delayed( | ||
| 226 | &key, | ||
| 227 | rela_path.as_ref(), | ||
| 228 | gix_filter::driver::Operation::Smudge, | ||
| 229 | )?, | ||
| 230 | ); | ||
| 231 | let (file, set_executable_after_creation) = match entry::open_file( | ||
| 232 | &std::mem::take(&mut delayed.validated_file_path), // mark it as seen, relevant for `unprocessed_paths` | ||
| 233 | destination_is_initially_empty, | ||
| 234 | overwrite_existing, | ||
| 235 | delayed.needs_executable_bit, | ||
| 236 | delayed.entry.mode, | ||
| 237 | ) { | ||
| 238 | Ok(res) => res, | ||
| 239 | Err(err) => { | ||
| 240 | if !is_collision(&err, delayed.entry_path, &mut out.collisions, files) { | ||
| 241 | handle_error(err, delayed.entry_path, files, &mut out.errors, ctx.options.keep_going)?; | ||
| 242 | } | ||
| 243 | std::io::copy(&mut read, &mut std::io::sink())?; | ||
| 244 | continue; | ||
| 245 | } | ||
| 246 | }; | ||
| 247 | let mut write = WriteWithProgress { | ||
| 248 | inner: std::io::BufWriter::with_capacity(512 * 1024, file), | ||
| 249 | progress: bytes, | ||
| 250 | }; | ||
| 251 | bytes_written += std::io::copy(&mut read, &mut write)?; | ||
| 252 | entry::finalize_entry( | ||
| 253 | delayed.entry, | ||
| 254 | write.inner.into_inner().map_err(std::io::IntoInnerError::into_error)?, | ||
| 255 | set_executable_after_creation.then(|| { | ||
| 256 | rela_path_as_path = gix_path::from_bstr(delayed.entry_path); | ||
| 257 | rela_path_as_path.as_ref() | ||
| 258 | }), | ||
| 259 | )?; | ||
| 260 | delayed_files += 1; | ||
| 261 | if let Some(files) = files { | ||
| 262 | files.fetch_add(1, Ordering::SeqCst); | ||
| 263 | } | ||
| 264 | } | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | let unprocessed_paths = delayed_filter_results | ||
| 269 | .into_iter() | ||
| 270 | .filter_map(|d| (!d.validated_file_path.as_os_str().is_empty()).then(|| d.entry_path.to_owned())) | ||
| 271 | .collect(); | ||
| 272 | |||
| 273 | if !keep_going && !unknown_paths.is_empty() { | ||
| 274 | return Err(checkout::Error::FilterPathsUnprocessed { | ||
| 275 | rela_paths: unprocessed_paths, | ||
| 276 | }); | ||
| 277 | } | ||
| 278 | |||
| 279 | out.delayed_paths_unknown = unknown_paths; | ||
| 280 | out.delayed_paths_unprocessed = unprocessed_paths; | ||
| 281 | out.bytes_written += bytes_written; | ||
| 282 | out.files += delayed_files; | ||
| 283 | Ok(()) | ||
| 284 | } | ||
| 285 | |||
| 286 | pub struct WriteWithProgress<'a, T> { | ||
| 287 | pub inner: T, | ||
| 288 | pub progress: Option<&'a AtomicUsize>, | ||
| 289 | } | ||
| 290 | |||
| 291 | impl<'a, T> std::io::Write for WriteWithProgress<'a, T> | ||
| 292 | where | ||
| 293 | T: std::io::Write, | ||
| 294 | { | ||
| 295 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| 296 | let written = self.inner.write(buf)?; | ||
| 297 | if let Some(progress) = self.progress { | ||
| 298 | progress.fetch_add(written as gix_features::progress::Step, Ordering::SeqCst); | ||
| 299 | } | ||
| 300 | Ok(written) | ||
| 301 | } | ||
| 302 | |||
| 303 | fn flush(&mut self) -> std::io::Result<()> { | ||
| 304 | self.inner.flush() | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | pub fn checkout_entry_handle_result<'entry, Find, E>( | ||
| 309 | entry: &'entry mut gix_index::Entry, | ||
| 310 | entry_path: &'entry BStr, | ||
| 311 | errors: &mut Vec<checkout::ErrorRecord>, | ||
| 312 | collisions: &mut Vec<checkout::Collision>, | ||
| 313 | files: Option<&AtomicUsize>, | ||
| 314 | bytes: Option<&AtomicUsize>, | ||
| 315 | Context { | ||
| 316 | find, | ||
| 317 | path_cache, | ||
| 318 | filters, | ||
| 319 | buf, | ||
| 320 | options, | ||
| 321 | }: &mut Context<Find>, | ||
| 322 | ) -> Result<entry::Outcome<'entry>, checkout::Error<E>> | ||
| 323 | where | ||
| 324 | Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Clone, | ||
| 325 | E: std::error::Error + Send + Sync + 'static, | ||
| 326 | { | ||
| 327 | let res = entry::checkout( | ||
| 328 | entry, | ||
| 329 | entry_path, | ||
| 330 | entry::Context { | ||
| 331 | find, | ||
| 332 | path_cache, | ||
| 333 | filters, | ||
| 334 | buf, | ||
| 335 | }, | ||
| 336 | *options, | ||
| 337 | ); | ||
| 338 | match res { | ||
| 339 | Ok(out) => { | ||
| 340 | if let Some(num) = out.as_bytes() { | ||
| 341 | if let Some(bytes) = bytes { | ||
| 342 | bytes.fetch_add(num, Ordering::SeqCst); | ||
| 343 | } | ||
| 344 | if let Some(files) = files { | ||
| 345 | files.fetch_add(1, Ordering::SeqCst); | ||
| 346 | } | ||
| 347 | } | ||
| 348 | Ok(out) | ||
| 349 | } | ||
| 350 | Err(checkout::Error::Io(err)) if is_collision(&err, entry_path, collisions, files) => { | ||
| 351 | Ok(entry::Outcome::Written { bytes: 0 }) | ||
| 352 | } | ||
| 353 | Err(err) => handle_error(err, entry_path, files, errors, options.keep_going) | ||
| 354 | .map(|()| entry::Outcome::Written { bytes: 0 }), | ||
| 355 | } | ||
| 356 | } | ||
| 357 | |||
| 358 | fn handle_error<E>( | ||
| 359 | err: E, | ||
| 360 | entry_path: &BStr, | ||
| 361 | files: Option<&AtomicUsize>, | ||
| 362 | errors: &mut Vec<checkout::ErrorRecord>, | ||
| 363 | keep_going: bool, | ||
| 364 | ) -> Result<(), E> | ||
| 365 | where | ||
| 366 | E: std::error::Error + Send + Sync + 'static, | ||
| 367 | { | ||
| 368 | if keep_going { | ||
| 369 | errors.push(checkout::ErrorRecord { | ||
| 370 | path: entry_path.into(), | ||
| 371 | error: Box::new(err), | ||
| 372 | }); | ||
| 373 | if let Some(files) = files { | ||
| 374 | files.fetch_add(1, Ordering::SeqCst); | ||
| 375 | } | ||
| 376 | Ok(()) | ||
| 377 | } else { | ||
| 378 | Err(err) | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | fn is_collision( | ||
| 383 | err: &std::io::Error, | ||
| 384 | entry_path: &BStr, | ||
| 385 | collisions: &mut Vec<checkout::Collision>, | ||
| 386 | files: Option<&AtomicUsize>, | ||
| 387 | ) -> bool { | ||
| 388 | if !gix_fs::symlink::is_collision_error(err) { | ||
| 389 | return false; | ||
| 390 | } | ||
| 391 | // We are here because a file existed or was blocked by a directory which shouldn't be possible unless | ||
| 392 | // we are on a file insensitive file system. | ||
| 393 | gix_features::trace::error!("{entry_path}: collided ({:?})", err.kind()); | ||
| 394 | collisions.push(checkout::Collision { | ||
| 395 | path: entry_path.into(), | ||
| 396 | error_kind: err.kind(), | ||
| 397 | }); | ||
| 398 | if let Some(files) = files { | ||
| 399 | files.fetch_add(1, Ordering::SeqCst); | ||
| 400 | } | ||
| 401 | true | ||
| 402 | } | ||
| @@ -1,294 +0,0 @@ | |||
| 1 | use std::{ | ||
| 2 | fs::OpenOptions, | ||
| 3 | io::Write, | ||
| 4 | path::{Path, PathBuf}, | ||
| 5 | }; | ||
| 6 | |||
| 7 | use bstr::BStr; | ||
| 8 | use gix_filter::{driver::apply::MaybeDelayed, pipeline::convert::ToWorktreeOutcome}; | ||
| 9 | use gix_hash::oid; | ||
| 10 | use gix_index::{entry::Stat, Entry}; | ||
| 11 | use io_close::Close; | ||
| 12 | |||
| 13 | use crate::Cache; | ||
| 14 | |||
| 15 | pub struct Context<'a, Find> { | ||
| 16 | pub find: &'a mut Find, | ||
| 17 | pub path_cache: &'a mut Cache, | ||
| 18 | pub filters: &'a mut gix_filter::Pipeline, | ||
| 19 | pub buf: &'a mut Vec<u8>, | ||
| 20 | } | ||
| 21 | |||
| 22 | /// A delayed result of a long-running filter process, which is made available as stream. | ||
| 23 | pub struct DelayedFilteredStream<'a> { | ||
| 24 | /// The key identifying the driver program | ||
| 25 | pub key: gix_filter::driver::Key, | ||
| 26 | /// If the file is going to be an executable. | ||
| 27 | pub needs_executable_bit: bool, | ||
| 28 | /// The validated path on disk at which the file should be placed. | ||
| 29 | pub validated_file_path: PathBuf, | ||
| 30 | /// The entry to adjust with the file we will write. | ||
| 31 | pub entry: &'a mut gix_index::Entry, | ||
| 32 | /// The relative path at which the entry resides (for use when querying the delayed entry). | ||
| 33 | pub entry_path: &'a BStr, | ||
| 34 | } | ||
| 35 | |||
| 36 | pub enum Outcome<'a> { | ||
| 37 | /// The file was written. | ||
| 38 | Written { | ||
| 39 | /// The amount of written bytes. | ||
| 40 | bytes: usize, | ||
| 41 | }, | ||
| 42 | /// The will be ready later. | ||
| 43 | Delayed(DelayedFilteredStream<'a>), | ||
| 44 | } | ||
| 45 | |||
| 46 | impl Outcome<'_> { | ||
| 47 | /// Return ourselves as (in-memory) bytes if possible. | ||
| 48 | pub fn as_bytes(&self) -> Option<usize> { | ||
| 49 | match self { | ||
| 50 | Outcome::Written { bytes } => Some(*bytes), | ||
| 51 | Outcome::Delayed { .. } => None, | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | #[cfg_attr(not(unix), allow(unused_variables))] | ||
| 57 | pub fn checkout<'entry, Find, E>( | ||
| 58 | entry: &'entry mut Entry, | ||
| 59 | entry_path: &'entry BStr, | ||
| 60 | Context { | ||
| 61 | find, | ||
| 62 | filters, | ||
| 63 | path_cache, | ||
| 64 | buf, | ||
| 65 | }: Context<'_, Find>, | ||
| 66 | crate::checkout::chunk::Options { | ||
| 67 | fs: gix_fs::Capabilities { | ||
| 68 | symlink, | ||
| 69 | executable_bit, | ||
| 70 | .. | ||
| 71 | }, | ||
| 72 | destination_is_initially_empty, | ||
| 73 | overwrite_existing, | ||
| 74 | filter_process_delay, | ||
| 75 | .. | ||
| 76 | }: crate::checkout::chunk::Options, | ||
| 77 | ) -> Result<Outcome<'entry>, crate::checkout::Error<E>> | ||
| 78 | where | ||
| 79 | Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E>, | ||
| 80 | E: std::error::Error + Send + Sync + 'static, | ||
| 81 | { | ||
| 82 | let dest_relative = gix_path::try_from_bstr(entry_path).map_err(|_| crate::checkout::Error::IllformedUtf8 { | ||
| 83 | path: entry_path.to_owned(), | ||
| 84 | })?; | ||
| 85 | let is_dir = Some(entry.mode == gix_index::entry::Mode::COMMIT || entry.mode == gix_index::entry::Mode::DIR); | ||
| 86 | let path_cache = path_cache.at_path(dest_relative, is_dir, &mut *find)?; | ||
| 87 | let dest = path_cache.path(); | ||
| 88 | |||
| 89 | let object_size = match entry.mode { | ||
| 90 | gix_index::entry::Mode::FILE | gix_index::entry::Mode::FILE_EXECUTABLE => { | ||
| 91 | let obj = find(&entry.id, buf).map_err(|err| crate::checkout::Error::Find { | ||
| 92 | err, | ||
| 93 | oid: entry.id, | ||
| 94 | path: dest.to_path_buf(), | ||
| 95 | })?; | ||
| 96 | |||
| 97 | let filtered = filters.convert_to_worktree( | ||
| 98 | obj.data, | ||
| 99 | entry_path, | ||
| 100 | |_, attrs| { | ||
| 101 | path_cache.matching_attributes(attrs); | ||
| 102 | }, | ||
| 103 | filter_process_delay, | ||
| 104 | )?; | ||
| 105 | let (num_bytes, file, set_executable_after_creation) = match filtered { | ||
| 106 | ToWorktreeOutcome::Unchanged(buf) | ToWorktreeOutcome::Buffer(buf) => { | ||
| 107 | let (mut file, flag) = open_file( | ||
| 108 | dest, | ||
| 109 | destination_is_initially_empty, | ||
| 110 | overwrite_existing, | ||
| 111 | executable_bit, | ||
| 112 | entry.mode, | ||
| 113 | )?; | ||
| 114 | file.write_all(buf)?; | ||
| 115 | (buf.len(), file, flag) | ||
| 116 | } | ||
| 117 | ToWorktreeOutcome::Process(MaybeDelayed::Immediate(mut filtered)) => { | ||
| 118 | let (mut file, flag) = open_file( | ||
| 119 | dest, | ||
| 120 | destination_is_initially_empty, | ||
| 121 | overwrite_existing, | ||
| 122 | executable_bit, | ||
| 123 | entry.mode, | ||
| 124 | )?; | ||
| 125 | let num_bytes = std::io::copy(&mut filtered, &mut file)? as usize; | ||
| 126 | (num_bytes, file, flag) | ||
| 127 | } | ||
| 128 | ToWorktreeOutcome::Process(MaybeDelayed::Delayed(key)) => { | ||
| 129 | return Ok(Outcome::Delayed(DelayedFilteredStream { | ||
| 130 | key, | ||
| 131 | needs_executable_bit: false, | ||
| 132 | validated_file_path: dest.to_owned(), | ||
| 133 | entry, | ||
| 134 | entry_path, | ||
| 135 | })) | ||
| 136 | } | ||
| 137 | }; | ||
| 138 | |||
| 139 | // For possibly existing, overwritten files, we must change the file mode explicitly. | ||
| 140 | finalize_entry(entry, file, set_executable_after_creation.then_some(dest))?; | ||
| 141 | num_bytes | ||
| 142 | } | ||
| 143 | gix_index::entry::Mode::SYMLINK => { | ||
| 144 | let obj = find(&entry.id, buf).map_err(|err| crate::checkout::Error::Find { | ||
| 145 | err, | ||
| 146 | oid: entry.id, | ||
| 147 | path: dest.to_path_buf(), | ||
| 148 | })?; | ||
| 149 | let symlink_destination = gix_path::try_from_byte_slice(obj.data) | ||
| 150 | .map_err(|_| crate::checkout::Error::IllformedUtf8 { path: obj.data.into() })?; | ||
| 151 | |||
| 152 | if symlink { | ||
| 153 | try_op_or_unlink(dest, overwrite_existing, |p| { | ||
| 154 | gix_fs::symlink::create(symlink_destination, p) | ||
| 155 | })?; | ||
| 156 | } else { | ||
| 157 | let mut file = try_op_or_unlink(dest, overwrite_existing, |p| { | ||
| 158 | open_options(p, destination_is_initially_empty, overwrite_existing).open(dest) | ||
| 159 | })?; | ||
| 160 | file.write_all(obj.data)?; | ||
| 161 | file.close()?; | ||
| 162 | } | ||
| 163 | |||
| 164 | entry.stat = Stat::from_fs(&std::fs::symlink_metadata(dest)?)?; | ||
| 165 | obj.data.len() | ||
| 166 | } | ||
| 167 | gix_index::entry::Mode::DIR => { | ||
| 168 | gix_features::trace::warn!( | ||
| 169 | "Skipped sparse directory at '{entry_path}' ({id}) as it cannot yet be handled", | ||
| 170 | id = entry.id | ||
| 171 | ); | ||
| 172 | 0 | ||
| 173 | } | ||
| 174 | gix_index::entry::Mode::COMMIT => { | ||
| 175 | gix_features::trace::warn!( | ||
| 176 | "Skipped submodule at '{entry_path}' ({id}) as it cannot yet be handled", | ||
| 177 | id = entry.id | ||
| 178 | ); | ||
| 179 | 0 | ||
| 180 | } | ||
| 181 | _ => unreachable!(), | ||
| 182 | }; | ||
| 183 | Ok(Outcome::Written { bytes: object_size }) | ||
| 184 | } | ||
| 185 | |||
| 186 | /// Note that this works only because we assume to not race ourselves when symlinks are involved, and we do this by | ||
| 187 | /// delaying symlink creation to the end and will always do that sequentially. | ||
| 188 | /// It's still possible to fall for a race if other actors create symlinks in our path, but that's nothing to defend against. | ||
| 189 | fn try_op_or_unlink<T>( | ||
| 190 | path: &Path, | ||
| 191 | overwrite_existing: bool, | ||
| 192 | op: impl Fn(&Path) -> std::io::Result<T>, | ||
| 193 | ) -> std::io::Result<T> { | ||
| 194 | if overwrite_existing { | ||
| 195 | match op(path) { | ||
| 196 | Ok(res) => Ok(res), | ||
| 197 | Err(err) if gix_fs::symlink::is_collision_error(&err) => { | ||
| 198 | try_unlink_path_recursively(path, &std::fs::symlink_metadata(path)?)?; | ||
| 199 | op(path) | ||
| 200 | } | ||
| 201 | Err(err) => Err(err), | ||
| 202 | } | ||
| 203 | } else { | ||
| 204 | op(path) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | fn try_unlink_path_recursively(path: &Path, path_meta: &std::fs::Metadata) -> std::io::Result<()> { | ||
| 209 | if path_meta.is_dir() { | ||
| 210 | std::fs::remove_dir_all(path) | ||
| 211 | } else if path_meta.file_type().is_symlink() { | ||
| 212 | gix_fs::symlink::remove(path) | ||
| 213 | } else { | ||
| 214 | std::fs::remove_file(path) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | #[cfg(not(debug_assertions))] | ||
| 219 | fn debug_assert_dest_is_no_symlink(_path: &Path) {} | ||
| 220 | |||
| 221 | /// This is a debug assertion as we expect the machinery calling this to prevent this possibility in the first place | ||
| 222 | #[cfg(debug_assertions)] | ||
| 223 | fn debug_assert_dest_is_no_symlink(path: &Path) { | ||
| 224 | if let Ok(meta) = path.metadata() { | ||
| 225 | debug_assert!( | ||
| 226 | !meta.file_type().is_symlink(), | ||
| 227 | "BUG: should not ever allow to overwrite/write-into the target of a symbolic link: {}", | ||
| 228 | path.display() | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | fn open_options(path: &Path, destination_is_initially_empty: bool, overwrite_existing: bool) -> OpenOptions { | ||
| 234 | if overwrite_existing || !destination_is_initially_empty { | ||
| 235 | debug_assert_dest_is_no_symlink(path); | ||
| 236 | } | ||
| 237 | let mut options = gix_features::fs::open_options_no_follow(); | ||
| 238 | options | ||
| 239 | .create_new(destination_is_initially_empty && !overwrite_existing) | ||
| 240 | .create(!destination_is_initially_empty || overwrite_existing) | ||
| 241 | .write(true); | ||
| 242 | options | ||
| 243 | } | ||
| 244 | |||
| 245 | pub(crate) fn open_file( | ||
| 246 | path: &Path, | ||
| 247 | destination_is_initially_empty: bool, | ||
| 248 | overwrite_existing: bool, | ||
| 249 | fs_supports_executable_bit: bool, | ||
| 250 | entry_mode: gix_index::entry::Mode, | ||
| 251 | ) -> std::io::Result<(std::fs::File, bool)> { | ||
| 252 | #[cfg_attr(windows, allow(unused_mut))] | ||
| 253 | let mut options = open_options(path, destination_is_initially_empty, overwrite_existing); | ||
| 254 | let needs_executable_bit = fs_supports_executable_bit && entry_mode == gix_index::entry::Mode::FILE_EXECUTABLE; | ||
| 255 | #[cfg(unix)] | ||
| 256 | let set_executable_after_creation = if needs_executable_bit && destination_is_initially_empty { | ||
| 257 | use std::os::unix::fs::OpenOptionsExt; | ||
| 258 | // Note that these only work if the file was newly created, but won't if it's already | ||
| 259 | // existing, possibly without the executable bit set. Thus we do this only if the file is new. | ||
| 260 | options.mode(0o777); | ||
| 261 | false | ||
| 262 | } else { | ||
| 263 | needs_executable_bit | ||
| 264 | }; | ||
| 265 | // not supported on windows | ||
| 266 | #[cfg(windows)] | ||
| 267 | let set_executable_after_creation = needs_executable_bit; | ||
| 268 | try_op_or_unlink(path, overwrite_existing, |p| options.open(p)).map(|f| (f, set_executable_after_creation)) | ||
| 269 | } | ||
| 270 | |||
| 271 | /// Close `file` and store its stats in `entry`, possibly setting `file` executable depending on `set_executable_after_creation`. | ||
| 272 | #[cfg_attr(windows, allow(unused_variables))] | ||
| 273 | pub(crate) fn finalize_entry<E>( | ||
| 274 | entry: &mut gix_index::Entry, | ||
| 275 | file: std::fs::File, | ||
| 276 | set_executable_after_creation: Option<&Path>, | ||
| 277 | ) -> Result<(), crate::checkout::Error<E>> | ||
| 278 | where | ||
| 279 | E: std::error::Error + Send + Sync + 'static, | ||
| 280 | { | ||
| 281 | // For possibly existing, overwritten files, we must change the file mode explicitly. | ||
| 282 | #[cfg(unix)] | ||
| 283 | if let Some(path) = set_executable_after_creation { | ||
| 284 | use std::os::unix::fs::PermissionsExt; | ||
| 285 | let mut perm = std::fs::symlink_metadata(path)?.permissions(); | ||
| 286 | perm.set_mode(0o777); | ||
| 287 | std::fs::set_permissions(path, perm)?; | ||
| 288 | } | ||
| 289 | // NOTE: we don't call `file.sync_all()` here knowing that some filesystems don't handle this well. | ||
| 290 | // revisit this once there is a bug to fix. | ||
| 291 | entry.stat = Stat::from_fs(&file.metadata()?)?; | ||
| 292 | file.close()?; | ||
| 293 | Ok(()) | ||
| 294 | } | ||
| @@ -1,168 +0,0 @@ | |||
| 1 | use std::sync::atomic::AtomicBool; | ||
| 2 | |||
| 3 | use gix_features::{interrupt, parallel::in_parallel_with_finalize, progress::Progress}; | ||
| 4 | use gix_hash::oid; | ||
| 5 | |||
| 6 | use crate::{cache, checkout::chunk, Cache}; | ||
| 7 | |||
| 8 | /// Checkout the entire `index` into `dir`, and resolve objects found in index entries with `find` to write their content to their | ||
| 9 | /// respective path in `dir`. | ||
| 10 | /// Use `files` to count each fully checked out file, and count the amount written `bytes`. If `should_interrupt` is `true`, the | ||
| 11 | /// operation will abort. | ||
| 12 | /// `options` provide a lot of context on how to perform the operation. | ||
| 13 | /// | ||
| 14 | /// ### Handling the return value | ||
| 15 | /// | ||
| 16 | /// Note that interruption still produce an `Ok(…)` value, so the caller should look at `should_interrupt` to communicate the outcome. | ||
| 17 | /// | ||
| 18 | #[allow(clippy::too_many_arguments)] | ||
| 19 | pub fn checkout<Find, E>( | ||
| 20 | index: &mut gix_index::State, | ||
| 21 | dir: impl Into<std::path::PathBuf>, | ||
| 22 | find: Find, | ||
| 23 | files: &mut impl Progress, | ||
| 24 | bytes: &mut impl Progress, | ||
| 25 | should_interrupt: &AtomicBool, | ||
| 26 | options: crate::checkout::Options, | ||
| 27 | ) -> Result<crate::checkout::Outcome, crate::checkout::Error<E>> | ||
| 28 | where | ||
| 29 | Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Send + Clone, | ||
| 30 | E: std::error::Error + Send + Sync + 'static, | ||
| 31 | { | ||
| 32 | let paths = index.take_path_backing(); | ||
| 33 | let res = checkout_inner(index, &paths, dir, find, files, bytes, should_interrupt, options); | ||
| 34 | index.return_path_backing(paths); | ||
| 35 | res | ||
| 36 | } | ||
| 37 | |||
| 38 | #[allow(clippy::too_many_arguments)] | ||
| 39 | fn checkout_inner<Find, E>( | ||
| 40 | index: &mut gix_index::State, | ||
| 41 | paths: &gix_index::PathStorage, | ||
| 42 | dir: impl Into<std::path::PathBuf>, | ||
| 43 | find: Find, | ||
| 44 | files: &mut impl Progress, | ||
| 45 | bytes: &mut impl Progress, | ||
| 46 | should_interrupt: &AtomicBool, | ||
| 47 | mut options: crate::checkout::Options, | ||
| 48 | ) -> Result<crate::checkout::Outcome, crate::checkout::Error<E>> | ||
| 49 | where | ||
| 50 | Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Send + Clone, | ||
| 51 | E: std::error::Error + Send + Sync + 'static, | ||
| 52 | { | ||
| 53 | let num_files = files.counter(); | ||
| 54 | let num_bytes = bytes.counter(); | ||
| 55 | let dir = dir.into(); | ||
| 56 | let case = if options.fs.ignore_case { | ||
| 57 | gix_glob::pattern::Case::Fold | ||
| 58 | } else { | ||
| 59 | gix_glob::pattern::Case::Sensitive | ||
| 60 | }; | ||
| 61 | let (chunk_size, thread_limit, num_threads) = gix_features::parallel::optimize_chunk_size_and_thread_limit( | ||
| 62 | 100, | ||
| 63 | index.entries().len().into(), | ||
| 64 | options.thread_limit, | ||
| 65 | None, | ||
| 66 | ); | ||
| 67 | |||
| 68 | let state = cache::State::for_checkout(options.overwrite_existing, std::mem::take(&mut options.attributes)); | ||
| 69 | let attribute_files = state.id_mappings_from_index(index, paths, case); | ||
| 70 | let mut ctx = chunk::Context { | ||
| 71 | buf: Vec::new(), | ||
| 72 | options: (&options).into(), | ||
| 73 | path_cache: Cache::new(dir, state, case, Vec::with_capacity(512), attribute_files), | ||
| 74 | filters: options.filters, | ||
| 75 | find, | ||
| 76 | }; | ||
| 77 | |||
| 78 | let chunk::Outcome { | ||
| 79 | mut collisions, | ||
| 80 | mut errors, | ||
| 81 | mut bytes_written, | ||
| 82 | files: files_updated, | ||
| 83 | delayed_symlinks, | ||
| 84 | delayed_paths_unknown, | ||
| 85 | delayed_paths_unprocessed, | ||
| 86 | } = if num_threads == 1 { | ||
| 87 | let entries_with_paths = interrupt::Iter::new(index.entries_mut_with_paths_in(paths), should_interrupt); | ||
| 88 | let mut delayed_filter_results = Vec::new(); | ||
| 89 | let mut out = chunk::process( | ||
| 90 | entries_with_paths, | ||
| 91 | num_files.as_deref(), | ||
| 92 | num_bytes.as_deref(), | ||
| 93 | &mut delayed_filter_results, | ||
| 94 | &mut ctx, | ||
| 95 | )?; | ||
| 96 | chunk::process_delayed_filter_results( | ||
| 97 | delayed_filter_results, | ||
| 98 | num_files.as_deref(), | ||
| 99 | num_bytes.as_deref(), | ||
| 100 | &mut out, | ||
| 101 | &mut ctx, | ||
| 102 | )?; | ||
| 103 | out | ||
| 104 | } else { | ||
| 105 | let entries_with_paths = interrupt::Iter::new(index.entries_mut_with_paths_in(paths), should_interrupt); | ||
| 106 | in_parallel_with_finalize( | ||
| 107 | gix_features::iter::Chunks { | ||
| 108 | inner: entries_with_paths, | ||
| 109 | size: chunk_size, | ||
| 110 | }, | ||
| 111 | thread_limit, | ||
| 112 | { | ||
| 113 | let ctx = ctx.clone(); | ||
| 114 | move |_| (Vec::new(), ctx) | ||
| 115 | }, | ||
| 116 | |chunk, (delayed_filter_results, ctx)| { | ||
| 117 | chunk::process( | ||
| 118 | chunk.into_iter(), | ||
| 119 | num_files.as_deref(), | ||
| 120 | num_bytes.as_deref(), | ||
| 121 | delayed_filter_results, | ||
| 122 | ctx, | ||
| 123 | ) | ||
| 124 | }, | ||
| 125 | |(delayed_filter_results, mut ctx)| { | ||
| 126 | let mut out = chunk::Outcome::default(); | ||
| 127 | chunk::process_delayed_filter_results( | ||
| 128 | delayed_filter_results, | ||
| 129 | num_files.as_deref(), | ||
| 130 | num_bytes.as_deref(), | ||
| 131 | &mut out, | ||
| 132 | &mut ctx, | ||
| 133 | )?; | ||
| 134 | Ok(out) | ||
| 135 | }, | ||
| 136 | chunk::Reduce { | ||
| 137 | files: num_files.is_none().then_some(files), | ||
| 138 | bytes: num_bytes.is_none().then_some(bytes), | ||
| 139 | aggregate: Default::default(), | ||
| 140 | marker: Default::default(), | ||
| 141 | }, | ||
| 142 | )? | ||
| 143 | }; | ||
| 144 | |||
| 145 | for (entry, entry_path) in delayed_symlinks { | ||
| 146 | bytes_written += chunk::checkout_entry_handle_result( | ||
| 147 | entry, | ||
| 148 | entry_path, | ||
| 149 | &mut errors, | ||
| 150 | &mut collisions, | ||
| 151 | num_files.as_deref(), | ||
| 152 | num_bytes.as_deref(), | ||
| 153 | &mut ctx, | ||
| 154 | )? | ||
| 155 | .as_bytes() | ||
| 156 | .expect("only symlinks are delayed here, they are never filtered (or delayed again)") | ||
| 157 | as u64; | ||
| 158 | } | ||
| 159 | |||
| 160 | Ok(crate::checkout::Outcome { | ||
| 161 | files_updated, | ||
| 162 | collisions, | ||
| 163 | errors, | ||
| 164 | bytes_written, | ||
| 165 | delayed_paths_unknown, | ||
| 166 | delayed_paths_unprocessed, | ||
| 167 | }) | ||
| 168 | } | ||
| @@ -1,101 +0,0 @@ | |||
| 1 | use bstr::BString; | ||
| 2 | use gix_index::entry::stat; | ||
| 3 | |||
| 4 | /// Information about a path that failed to checkout as something else was already present. | ||
| 5 | #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] | ||
| 6 | pub struct Collision { | ||
| 7 | /// the path that collided with something already present on disk. | ||
| 8 | pub path: BString, | ||
| 9 | /// The io error we encountered when checking out `path`. | ||
| 10 | pub error_kind: std::io::ErrorKind, | ||
| 11 | } | ||
| 12 | |||
| 13 | /// A path that encountered an IO error. | ||
| 14 | pub struct ErrorRecord { | ||
| 15 | /// the path that encountered the error. | ||
| 16 | pub path: BString, | ||
| 17 | /// The error | ||
| 18 | pub error: Box<dyn std::error::Error + Send + Sync + 'static>, | ||
| 19 | } | ||
| 20 | |||
| 21 | /// The outcome of checking out an entire index. | ||
| 22 | #[derive(Default)] | ||
| 23 | pub struct Outcome { | ||
| 24 | /// The amount of files updated, or created. | ||
| 25 | pub files_updated: usize, | ||
| 26 | /// The amount of bytes written to disk, | ||
| 27 | pub bytes_written: u64, | ||
| 28 | /// The encountered collisions, which can happen on a case-insensitive filesystem. | ||
| 29 | pub collisions: Vec<Collision>, | ||
| 30 | /// Other errors that happened during checkout. | ||
| 31 | pub errors: Vec<ErrorRecord>, | ||
| 32 | /// Relative paths that the process listed as 'delayed' even though we never passed them. | ||
| 33 | pub delayed_paths_unknown: Vec<BString>, | ||
| 34 | /// All paths that were left unprocessed, because they were never listed by the process even though we passed them. | ||
| 35 | pub delayed_paths_unprocessed: Vec<BString>, | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Options to further configure the checkout operation. | ||
| 39 | #[derive(Clone, Default)] | ||
| 40 | pub struct Options { | ||
| 41 | /// capabilities of the file system | ||
| 42 | pub fs: gix_fs::Capabilities, | ||
| 43 | /// If set, don't use more than this amount of threads. | ||
| 44 | /// Otherwise, usually use as many threads as there are logical cores. | ||
| 45 | /// A value of 0 is interpreted as no-limit | ||
| 46 | pub thread_limit: Option<usize>, | ||
| 47 | /// If true, we assume no file to exist in the target directory, and want exclusive access to it. | ||
| 48 | /// This should be enabled when cloning to avoid checks for freshness of files. This also enables | ||
| 49 | /// detection of collisions based on whether or not exclusive file creation succeeds or fails. | ||
| 50 | pub destination_is_initially_empty: bool, | ||
| 51 | /// If true, default false, worktree entries on disk will be overwritten with content from the index | ||
| 52 | /// even if they appear to be changed. When creating directories that clash with existing worktree entries, | ||
| 53 | /// these will try to delete the existing entry. | ||
| 54 | /// This is similar in behaviour as `git checkout --force`. | ||
| 55 | pub overwrite_existing: bool, | ||
| 56 | /// If true, default false, try to checkout as much as possible and don't abort on first error which isn't | ||
| 57 | /// due to a conflict. | ||
| 58 | /// The checkout operation will never fail, but count the encountered errors instead along with their paths. | ||
| 59 | pub keep_going: bool, | ||
| 60 | /// Control how stat comparisons are made when checking if a file is fresh. | ||
| 61 | pub stat_options: stat::Options, | ||
| 62 | /// A stack of attributes to use with the filesystem cache to use as driver for filters. | ||
| 63 | pub attributes: crate::cache::state::Attributes, | ||
| 64 | /// The filter pipeline to use for applying mandatory filters before writing to the worktree. | ||
| 65 | pub filters: gix_filter::Pipeline, | ||
| 66 | /// Control how long-running processes may use the 'delay' capability. | ||
| 67 | pub filter_process_delay: gix_filter::driver::apply::Delay, | ||
| 68 | } | ||
| 69 | |||
| 70 | /// The error returned by the [checkout()][crate::checkout()] function. | ||
| 71 | #[derive(Debug, thiserror::Error)] | ||
| 72 | #[allow(missing_docs)] | ||
| 73 | pub enum Error<E: std::error::Error + Send + Sync + 'static> { | ||
| 74 | #[error("Could not convert path to UTF8: {}", .path)] | ||
| 75 | IllformedUtf8 { path: BString }, | ||
| 76 | #[error("The clock was off when reading file related metadata after updating a file on disk")] | ||
| 77 | Time(#[from] std::time::SystemTimeError), | ||
| 78 | #[error("IO error while writing blob or reading file metadata or changing filetype")] | ||
| 79 | Io(#[from] std::io::Error), | ||
| 80 | #[error("object {} for checkout at {} could not be retrieved from object database", .oid.to_hex(), .path.display())] | ||
| 81 | Find { | ||
| 82 | #[source] | ||
| 83 | err: E, | ||
| 84 | oid: gix_hash::ObjectId, | ||
| 85 | path: std::path::PathBuf, | ||
| 86 | }, | ||
| 87 | #[error(transparent)] | ||
| 88 | Filter(#[from] gix_filter::pipeline::convert::to_worktree::Error), | ||
| 89 | #[error(transparent)] | ||
| 90 | FilterListDelayed(#[from] gix_filter::driver::delayed::list::Error), | ||
| 91 | #[error(transparent)] | ||
| 92 | FilterFetchDelayed(#[from] gix_filter::driver::delayed::fetch::Error), | ||
| 93 | #[error("The entry at path '{rela_path}' was listed as delayed by the filter process, but we never passed it")] | ||
| 94 | FilterPathUnknown { rela_path: BString }, | ||
| 95 | #[error("The following paths were delayed and apparently forgotten to be processed by the filter driver: ")] | ||
| 96 | FilterPathsUnprocessed { rela_paths: Vec<BString> }, | ||
| 97 | } | ||
| 98 | |||
| 99 | mod chunk; | ||
| 100 | mod entry; | ||
| 101 | pub(crate) mod function; | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | //! A crate with all index-centric functionality that is interacting with a worktree. | 1 | //! A crate with utility types for use by other crates that implement specifics. |
| 2 | //! | 2 | //! |
| 3 | //! Unless specified differently, all operations need an index file (e.g. `.git/index`) as driver. | 3 | //! Unless specified differently, all operations need an index file (e.g. `.git/index`) as driver. |
| 4 | //! | 4 | //! |
| @@ -11,9 +11,6 @@ · 6 unmodified lines | |||
| 11 | #![deny(missing_docs, rust_2018_idioms, unsafe_code)] | 11 | #![deny(missing_docs, rust_2018_idioms, unsafe_code)] |
| 12 | use bstr::BString; | 12 | use bstr::BString; |
| 13 | 13 | ||
| 14 | /// | ||
| 15 | pub mod read; | ||
| 16 | |||
| 17 | /// A cache for efficiently executing operations on directories and files which are encountered in sorted order. | 14 | /// A cache for efficiently executing operations on directories and files which are encountered in sorted order. |
| 18 | /// That way, these operations can be re-used for subsequent invocations in the same directory. | 15 | /// That way, these operations can be re-used for subsequent invocations in the same directory. |
| 19 | /// | 16 | /// |
| @@ -52,9 +49,3 @@ pub(crate) type PathIdMapping = (BString, gix_hash::ObjectId); · 32 unmodified lines | |||
| 52 | 49 | ||
| 53 | /// | 50 | /// |
| 54 | pub mod cache; | 51 | pub mod cache; |
| 55 | /// | ||
| 56 | pub mod checkout; | ||
| 57 | pub use checkout::function::checkout; | ||
| 58 | |||
| 59 | pub mod status; | ||
| 60 | pub use status::function::status; | ||
| @@ -1,64 +0,0 @@ | |||
| 1 | //! This module allows creating git blobs from worktree files. | ||
| 2 | //! | ||
| 3 | //! For the most part a blob just contains the raw on-disk data. However symlinks need to be considered properly | ||
| 4 | //! and attributes/config options need to be considered. | ||
| 5 | |||
| 6 | use std::{ | ||
| 7 | fs::{read_link, File}, | ||
| 8 | io::{self, Read}, | ||
| 9 | path::Path, | ||
| 10 | }; | ||
| 11 | |||
| 12 | use gix_object::Blob; | ||
| 13 | use gix_path as path; | ||
| 14 | |||
| 15 | // TODO: tests | ||
| 16 | |||
| 17 | // TODO: what to do about precompose unicode and ignore_case for symlinks | ||
| 18 | |||
| 19 | /// Create a blob from a file or symlink. | ||
| 20 | pub fn blob(path: &Path, capabilities: &gix_fs::Capabilities) -> io::Result<Blob> { | ||
| 21 | let mut data = Vec::new(); | ||
| 22 | data_to_buf(path, &mut data, capabilities)?; | ||
| 23 | Ok(Blob { data }) | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Create a blob from a file or symlink. | ||
| 27 | pub fn blob_with_meta(path: &Path, is_symlink: bool, capabilities: &gix_fs::Capabilities) -> io::Result<Blob> { | ||
| 28 | let mut data = Vec::new(); | ||
| 29 | data_to_buf_with_meta(path, &mut data, is_symlink, capabilities)?; | ||
| 30 | Ok(Blob { data }) | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Create blob data from a file or symlink. | ||
| 34 | pub fn data_to_buf<'a>(path: &Path, buf: &'a mut Vec<u8>, capabilities: &gix_fs::Capabilities) -> io::Result<&'a [u8]> { | ||
| 35 | data_to_buf_with_meta(path, buf, path.symlink_metadata()?.is_symlink(), capabilities) | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Create a blob from a file or symlink. | ||
| 39 | pub fn data_to_buf_with_meta<'a>( | ||
| 40 | path: &Path, | ||
| 41 | buf: &'a mut Vec<u8>, | ||
| 42 | is_symlink: bool, | ||
| 43 | capabilities: &gix_fs::Capabilities, | ||
| 44 | ) -> io::Result<&'a [u8]> { | ||
| 45 | buf.clear(); | ||
| 46 | // symlinks are only stored as actual symlinks if the FS supports it otherwise they are just | ||
| 47 | // normal files with their content equal to the linked path (so can be read normally) | ||
| 48 | // | ||
| 49 | if is_symlink && capabilities.symlink { | ||
| 50 | // conversion to bstr can never fail because symlinks are only used | ||
| 51 | // on unix (by git) so no reason to use the try version here | ||
| 52 | let symlink_path = path::into_bstr(read_link(path)?); | ||
| 53 | buf.extend_from_slice(&symlink_path); | ||
| 54 | // TODO: there is no reason this should be a clone | ||
| 55 | // std isn't great about allowing users to avoid allocations but we could | ||
| 56 | // simply write our own wrapper around libc::readlink which reuses the | ||
| 57 | // buffer. This would require unsafe code tough (obviously) | ||
| 58 | } else { | ||
| 59 | buf.clear(); | ||
| 60 | File::open(path)?.read_to_end(buf)?; | ||
| 61 | // TODO apply filters | ||
| 62 | } | ||
| 63 | Ok(buf.as_slice()) | ||
| 64 | } | ||
| @@ -1,80 +0,0 @@ | |||
| 1 | use gix_hash::ObjectId; | ||
| 2 | use gix_index as index; | ||
| 3 | use index::Entry; | ||
| 4 | |||
| 5 | /// Compares the content of two blobs in some way. | ||
| 6 | pub trait CompareBlobs { | ||
| 7 | /// Output data produced by [`compare_blobs()`][CompareBlobs::compare_blobs()]. | ||
| 8 | type Output; | ||
| 9 | |||
| 10 | /// Providing the underlying index `entry`, allow comparing a file in the worktree of size `worktree_blob_size` | ||
| 11 | /// and allow reading its bytes using `worktree_blob`. | ||
| 12 | /// If this function returns `None` the `entry` and the `worktree_blob` are assumed to be identical. | ||
| 13 | /// Use `entry_blob` to obtain the data for the blob referred to by `entry`, allowing comparisons of the data itself. | ||
| 14 | fn compare_blobs<'a, E>( | ||
| 15 | &mut self, | ||
| 16 | entry: &'a gix_index::Entry, | ||
| 17 | worktree_blob_size: usize, | ||
| 18 | worktree_blob: impl ReadDataOnce<'a, E>, | ||
| 19 | entry_blob: impl ReadDataOnce<'a, E>, | ||
| 20 | ) -> Result<Option<Self::Output>, E>; | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Lazy borrowed access to blob data. | ||
| 24 | pub trait ReadDataOnce<'a, E> { | ||
| 25 | /// Returns the contents of this blob. | ||
| 26 | /// | ||
| 27 | /// This potentially performs IO and other expensive operations | ||
| 28 | /// and should only be called when necessary. | ||
| 29 | fn read_data(self) -> Result<&'a [u8], E>; | ||
| 30 | } | ||
| 31 | |||
| 32 | /// Compares to blobs by comparing their size and oid, and only looks at the file if | ||
| 33 | /// the size matches, therefore it's very fast. | ||
| 34 | #[derive(Clone)] | ||
| 35 | pub struct FastEq; | ||
| 36 | |||
| 37 | impl CompareBlobs for FastEq { | ||
| 38 | type Output = (); | ||
| 39 | |||
| 40 | fn compare_blobs<'a, E>( | ||
| 41 | &mut self, | ||
| 42 | entry: &'a Entry, | ||
| 43 | worktree_blob_size: usize, | ||
| 44 | worktree_blob: impl ReadDataOnce<'a, E>, | ||
| 45 | _entry_blob: impl ReadDataOnce<'a, E>, | ||
| 46 | ) -> Result<Option<Self::Output>, E> { | ||
| 47 | // make sure to account for racily smudged entries here so that they don't always keep | ||
| 48 | // showing up as modified even after their contents have changed again, to a potentially | ||
| 49 | // unmodified state. That means that we want to ignore stat.size == 0 for non_empty_blobs. | ||
| 50 | if entry.stat.size as usize != worktree_blob_size && (entry.id.is_empty_blob() || entry.stat.size != 0) { | ||
| 51 | return Ok(Some(())); | ||
| 52 | } | ||
| 53 | let blob = worktree_blob.read_data()?; | ||
| 54 | let file_hash = gix_object::compute_hash(entry.id.kind(), gix_object::Kind::Blob, blob); | ||
| 55 | Ok((entry.id != file_hash).then_some(())) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Compares files to blobs by *always* comparing their hashes. | ||
| 60 | /// | ||
| 61 | /// Same as [`FastEq`] but does not contain a fast path for files with mismatched files and | ||
| 62 | /// therefore always returns an OID that can be reused later. | ||
| 63 | #[derive(Clone)] | ||
| 64 | pub struct HashEq; | ||
| 65 | |||
| 66 | impl CompareBlobs for HashEq { | ||
| 67 | type Output = ObjectId; | ||
| 68 | |||
| 69 | fn compare_blobs<'a, E>( | ||
| 70 | &mut self, | ||
| 71 | entry: &'a Entry, | ||
| 72 | _worktree_blob_size: usize, | ||
| 73 | worktree_blob: impl ReadDataOnce<'a, E>, | ||
| 74 | _entry_blob: impl ReadDataOnce<'a, E>, | ||
| 75 | ) -> Result<Option<Self::Output>, E> { | ||
| 76 | let blob = worktree_blob.read_data()?; | ||
| 77 | let file_hash = gix_object::compute_hash(entry.id.kind(), gix_object::Kind::Blob, blob); | ||
| 78 | Ok((entry.id != file_hash).then_some(file_hash)) | ||
| 79 | } | ||
| 80 | } | ||
| @@ -1,331 +0,0 @@ | |||
| 1 | use std::{io, marker::PhantomData, path::Path}; | ||
| 2 | |||
| 3 | use bstr::BStr; | ||
| 4 | use filetime::FileTime; | ||
| 5 | use gix_features::parallel::{in_parallel_if, Reduce}; | ||
| 6 | |||
| 7 | use crate::{ | ||
| 8 | read, | ||
| 9 | status::{ | ||
| 10 | content, | ||
| 11 | content::CompareBlobs, | ||
| 12 | types::{Error, Options}, | ||
| 13 | Change, VisitEntry, | ||
| 14 | }, | ||
| 15 | }; | ||
| 16 | |||
| 17 | /// Calculates the changes that need to be applied to an `index` to match the state of the `worktree` and makes them | ||
| 18 | /// observable in `collector`, along with information produced by `compare` which gets to see blobs that may have changes. | ||
| 19 | /// `options` are used to configure the operation. | ||
| 20 | /// | ||
| 21 | /// Note that `index` is updated with the latest seen stat information from the worktree, and its timestamp is adjusted to | ||
| 22 | /// the current time for which it will be considered fresh. | ||
| 23 | /// | ||
| 24 | /// Note that this isn't technically quite what this function does as this also provides some additional information, | ||
| 25 | /// like whether a file has conflicts, and files that were added with `git add` are shown as a special | ||
| 26 | /// changes despite not technically requiring a change to the index since `git add` already added the file to the index. | ||
| 27 | pub fn status<'index, T, Find, E>( | ||
| 28 | index: &'index mut gix_index::State, | ||
| 29 | worktree: &Path, | ||
| 30 | collector: &mut impl VisitEntry<'index, ContentChange = T>, | ||
| 31 | compare: impl CompareBlobs<Output = T> + Send + Clone, | ||
| 32 | find: Find, | ||
| 33 | options: Options, | ||
| 34 | ) -> Result<(), Error> | ||
| 35 | where | ||
| 36 | T: Send, | ||
| 37 | E: std::error::Error + Send + Sync + 'static, | ||
| 38 | Find: for<'a> FnMut(&gix_hash::oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Send + Clone, | ||
| 39 | { | ||
| 40 | // the order is absolutely critical here we use the old timestamp to detect racy index entries | ||
| 41 | // (modified at or after the last index update) during the index update we then set those | ||
| 42 | // entries size to 0 (see below) to ensure they keep showing up as racy and reset the timestamp. | ||
| 43 | let timestamp = index.timestamp(); | ||
| 44 | index.set_timestamp(FileTime::now()); | ||
| 45 | let (chunk_size, thread_limit, _) = gix_features::parallel::optimize_chunk_size_and_thread_limit( | ||
| 46 | 100, | ||
| 47 | index.entries().len().into(), | ||
| 48 | options.thread_limit, | ||
| 49 | None, | ||
| 50 | ); | ||
| 51 | let (entries, path_backing) = index.entries_mut_and_pathbacking(); | ||
| 52 | in_parallel_if( | ||
| 53 | || true, // TODO: heuristic: when is parallelization not worth it? | ||
| 54 | entries.chunks_mut(chunk_size), | ||
| 55 | thread_limit, | ||
| 56 | { | ||
| 57 | let options = &options; | ||
| 58 | move |_| { | ||
| 59 | ( | ||
| 60 | State { | ||
| 61 | buf: Vec::new(), | ||
| 62 | odb_buf: Vec::new(), | ||
| 63 | timestamp, | ||
| 64 | path_backing, | ||
| 65 | worktree, | ||
| 66 | options, | ||
| 67 | }, | ||
| 68 | compare.clone(), | ||
| 69 | find.clone(), | ||
| 70 | ) | ||
| 71 | } | ||
| 72 | }, | ||
| 73 | |entries, (state, diff, find)| { | ||
| 74 | entries | ||
| 75 | .iter_mut() | ||
| 76 | .filter_map(|entry| state.process(entry, diff, find)) | ||
| 77 | .collect() | ||
| 78 | }, | ||
| 79 | ReduceChange { | ||
| 80 | collector, | ||
| 81 | phantom: PhantomData, | ||
| 82 | }, | ||
| 83 | ) | ||
| 84 | } | ||
| 85 | |||
| 86 | struct State<'a, 'b> { | ||
| 87 | buf: Vec<u8>, | ||
| 88 | odb_buf: Vec<u8>, | ||
| 89 | timestamp: FileTime, | ||
| 90 | // path_cache: fs::Cache TODO path cache | ||
| 91 | path_backing: &'b [u8], | ||
| 92 | worktree: &'a Path, | ||
| 93 | options: &'a Options, | ||
| 94 | } | ||
| 95 | |||
| 96 | type StatusResult<'index, T> = Result<(&'index gix_index::Entry, &'index BStr, Option<Change<T>>, bool), Error>; | ||
| 97 | |||
| 98 | impl<'index> State<'_, 'index> { | ||
| 99 | fn process<T, Find, E>( | ||
| 100 | &mut self, | ||
| 101 | entry: &'index mut gix_index::Entry, | ||
| 102 | diff: &mut impl CompareBlobs<Output = T>, | ||
| 103 | find: &mut Find, | ||
| 104 | ) -> Option<StatusResult<'index, T>> | ||
| 105 | where | ||
| 106 | E: std::error::Error + Send + Sync + 'static, | ||
| 107 | Find: for<'a> FnMut(&gix_hash::oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Send + Clone, | ||
| 108 | { | ||
| 109 | let conflict = match entry.stage() { | ||
| 110 | 0 => false, | ||
| 111 | 1 => true, | ||
| 112 | _ => return None, | ||
| 113 | }; | ||
| 114 | if entry.flags.intersects( | ||
| 115 | gix_index::entry::Flags::UPTODATE | ||
| 116 | | gix_index::entry::Flags::SKIP_WORKTREE | ||
| 117 | | gix_index::entry::Flags::ASSUME_VALID | ||
| 118 | | gix_index::entry::Flags::FSMONITOR_VALID, | ||
| 119 | ) { | ||
| 120 | return None; | ||
| 121 | } | ||
| 122 | let path = entry.path_in(self.path_backing); | ||
| 123 | let status = self.compute_status(&mut *entry, path, diff, find); | ||
| 124 | Some(status.map(move |status| (&*entry, path, status, conflict))) | ||
| 125 | } | ||
| 126 | |||
| 127 | /// # On how racy-git is handled here | ||
| 128 | /// | ||
| 129 | /// Basically the racy detection is a safety mechanism that ensures we can always just compare the stat | ||
| 130 | /// information between index and worktree and if they match we don't need to look at the content. | ||
| 131 | /// This usually just works but if a file updates quickly we could run into the following situation: | ||
| 132 | /// | ||
| 133 | /// * save file version `A` from disk into worktree (git add) | ||
| 134 | /// * file is changed so fast that the mtime doesn't change - *we only looks at seconds by default* | ||
| 135 | /// * file contents change but file-size stays the same, so `"foo" -> "bar"` has the same size but different content | ||
| 136 | /// | ||
| 137 | /// Now both `mtime` and `size`, and all other stat information, is the same but the file has actually changed. | ||
| 138 | /// This case is called *racily clean*. *The file should show up as changed but due to a data race it doesn't.* | ||
| 139 | /// This is the racy git problem. | ||
| 140 | /// | ||
| 141 | /// To solve this we do the following trick: Whenever we modify the index, which includes `git status`, we save the | ||
| 142 | /// current timestamp before the modification starts. This timestamp fundamentally represents a checkpoint of sorts. | ||
| 143 | /// We "promise" ourselves that after the modification finishes all entries modified before this timestamp have the | ||
| 144 | /// racy git problem resolved. | ||
| 145 | /// | ||
| 146 | /// So now when we modify the index we must resolve the racy git problem somehow. To do that we only need to look at | ||
| 147 | /// unchanged entries. Changed entries are not interesting since they are already showing up as changed anyway so there | ||
| 148 | /// isn't really a race-condition to worry about. This also explains why removing the `return` here doesn't have an apparent effect. | ||
| 149 | /// This entire branch here is just the optimization of "don't even look at index entries where the stat hasn't changed". | ||
| 150 | /// If we don't have this optimization the result shouldn't change, our status implementation will just be super slow :D | ||
| 151 | |||
| 152 | /// We calculate whether this change is `racy_clean`, so if the last `timestamp` is before or the same as the `mtime` of the entry | ||
| 153 | /// which is what `new_stat.is_racy(..)` does in the branch, and only if we are sure that there is no race condition | ||
| 154 | /// do we `return` early. Since we don't `return` early we just do a full content comparison below, | ||
| 155 | /// which always yields the correct result, there is no race condition there. | ||
| 156 | /// | ||
| 157 | /// If a file showed up as racily clean and didn't change then we don't need to do anything. After this status check is | ||
| 158 | /// complete and the file won't show up as racily clean anymore, since it's mtime is now before the new timestamp. | ||
| 159 | /// However if the file did actually change then we really ran into one of those rare race conditions in that case we, | ||
| 160 | /// and git does the same, set the size of the file in the index to 0. This will always make the file show up as changed. | ||
| 161 | /// This adds the need to treat all files of size 0 in the index as changed. This is not quite right of course because 0 sized files | ||
| 162 | /// could be entirely valid and unchanged. Therefore this only applies if the oid doesn't match the oid of an empty file, | ||
| 163 | /// which is a constant. | ||
| 164 | /// | ||
| 165 | /// Adapted from [here](https://github.com/Byron/gitoxide/pull/805#discussion_r1164676777). | ||
| 166 | fn compute_status<T, Find, E>( | ||
| 167 | &mut self, | ||
| 168 | entry: &mut gix_index::Entry, | ||
| 169 | git_path: &BStr, | ||
| 170 | diff: &mut impl CompareBlobs<Output = T>, | ||
| 171 | find: &mut Find, | ||
| 172 | ) -> Result<Option<Change<T>>, Error> | ||
| 173 | where | ||
| 174 | E: std::error::Error + Send + Sync + 'static, | ||
| 175 | Find: for<'a> FnMut(&gix_hash::oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E> + Send + Clone, | ||
| 176 | { | ||
| 177 | // TODO fs cache | ||
| 178 | let worktree_path = gix_path::try_from_bstr(git_path).map_err(|_| Error::IllformedUtf8)?; | ||
| 179 | let worktree_path = self.worktree.join(worktree_path); | ||
| 180 | let metadata = match worktree_path.symlink_metadata() { | ||
| 181 | // TODO: check if any parent directory is a symlink | ||
| 182 | // we need to use fs::Cache for that | ||
| 183 | Ok(metadata) if metadata.is_dir() => { | ||
| 184 | // index entries are normally only for files/symlinks | ||
| 185 | // if a file turned into a directory it was removed | ||
| 186 | // the only exception here are submodules which are | ||
| 187 | // part of the index despite being directories | ||
| 188 | // | ||
| 189 | // TODO: submodules: | ||
| 190 | // if entry.mode.contains(Mode::COMMIT) && | ||
| 191 | // resolve_gitlink_ref(ce->name, "HEAD", &sub)) | ||
| 192 | return Ok(Some(Change::Removed)); | ||
| 193 | } | ||
| 194 | Ok(metadata) => metadata, | ||
| 195 | Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Some(Change::Removed)), | ||
| 196 | Err(err) => { | ||
| 197 | return Err(err.into()); | ||
| 198 | } | ||
| 199 | }; | ||
| 200 | if entry.flags.contains(gix_index::entry::Flags::INTENT_TO_ADD) { | ||
| 201 | return Ok(Some(Change::IntentToAdd)); | ||
| 202 | } | ||
| 203 | let new_stat = gix_index::entry::Stat::from_fs(&metadata)?; | ||
| 204 | let executable_bit_changed = | ||
| 205 | match entry | ||
| 206 | .mode | ||
| 207 | .change_to_match_fs(&metadata, self.options.fs.symlink, self.options.fs.executable_bit) | ||
| 208 | { | ||
| 209 | Some(gix_index::entry::mode::Change::Type { .. }) => return Ok(Some(Change::Type)), | ||
| 210 | Some(gix_index::entry::mode::Change::ExecutableBit) => true, | ||
| 211 | None => false, | ||
| 212 | }; | ||
| 213 | |||
| 214 | // Here we implement racy-git. See racy-git.txt in the git documentation for a detailed documentation. | ||
| 215 | // | ||
| 216 | // A file is racy if: | ||
| 217 | // 1. its `mtime` is at or after the last index timestamp and its entry stat information | ||
| 218 | // matches the on-disk file but the file contents are actually modified | ||
| 219 | // 2. it's size is 0 (set after detecting a file was racy previously) | ||
| 220 | // | ||
| 221 | // The first case is detected below by checking the timestamp if the file is marked unmodified. | ||
| 222 | // The second case is usually detected either because the on-disk file is not empty, hence | ||
| 223 | // the basic stat match fails, or by checking whether the size doesn't fit the oid. | ||
| 224 | let mut racy_clean = false; | ||
| 225 | if !executable_bit_changed | ||
| 226 | && new_stat.matches(&entry.stat, self.options.stat) | ||
| 227 | // TODO: find a test for the following line or remove it. Is this more often hit with smudge/clean filters? | ||
| 228 | && (!entry.id.is_empty_blob() || entry.stat.size == 0) | ||
| 229 | { | ||
| 230 | racy_clean = new_stat.is_racy(self.timestamp, self.options.stat); | ||
| 231 | if !racy_clean { | ||
| 232 | return Ok(None); | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 236 | let read_file = WorktreeBlob { | ||
| 237 | buf: &mut self.buf, | ||
| 238 | path: &worktree_path, | ||
| 239 | entry, | ||
| 240 | options: self.options, | ||
| 241 | }; | ||
| 242 | let read_blob = OdbBlob { | ||
| 243 | buf: &mut self.odb_buf, | ||
| 244 | id: &entry.id, | ||
| 245 | find, | ||
| 246 | }; | ||
| 247 | let content_change = diff.compare_blobs::<Error>(entry, metadata.len() as usize, read_file, read_blob)?; | ||
| 248 | // This file is racy clean! Set the size to 0 so we keep detecting this as the file is updated. | ||
| 249 | if content_change.is_some() && racy_clean { | ||
| 250 | entry.stat.size = 0; | ||
| 251 | } | ||
| 252 | if content_change.is_some() || executable_bit_changed { | ||
| 253 | Ok(Some(Change::Modification { | ||
| 254 | executable_bit_changed, | ||
| 255 | content_change, | ||
| 256 | })) | ||
| 257 | } else { | ||
| 258 | // don't diff against this file next time since we know the file is unchanged. | ||
| 259 | entry.stat = new_stat; | ||
| 260 | Ok(None) | ||
| 261 | } | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | struct ReduceChange<'a, 'index, T: VisitEntry<'index>> { | ||
| 266 | collector: &'a mut T, | ||
| 267 | phantom: PhantomData<fn(&'index ())>, | ||
| 268 | } | ||
| 269 | |||
| 270 | impl<'index, T, C: VisitEntry<'index, ContentChange = T>> Reduce for ReduceChange<'_, 'index, C> { | ||
| 271 | type Input = Vec<StatusResult<'index, T>>; | ||
| 272 | |||
| 273 | type FeedProduce = (); | ||
| 274 | |||
| 275 | type Output = (); | ||
| 276 | |||
| 277 | type Error = Error; | ||
| 278 | |||
| 279 | fn feed(&mut self, items: Self::Input) -> Result<Self::FeedProduce, Self::Error> { | ||
| 280 | for item in items { | ||
| 281 | let (entry, path, change, conflict) = item?; | ||
| 282 | self.collector.visit_entry(entry, path, change, conflict); | ||
| 283 | } | ||
| 284 | Ok(()) | ||
| 285 | } | ||
| 286 | |||
| 287 | fn finalize(self) -> Result<Self::Output, Self::Error> { | ||
| 288 | Ok(()) | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | struct WorktreeBlob<'a> { | ||
| 293 | buf: &'a mut Vec<u8>, | ||
| 294 | path: &'a Path, | ||
| 295 | entry: &'a gix_index::Entry, | ||
| 296 | options: &'a Options, | ||
| 297 | } | ||
| 298 | |||
| 299 | struct OdbBlob<'a, Find, E> | ||
| 300 | where | ||
| 301 | E: std::error::Error + Send + Sync + 'static, | ||
| 302 | Find: FnMut(&gix_hash::oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E>, | ||
| 303 | { | ||
| 304 | buf: &'a mut Vec<u8>, | ||
| 305 | id: &'a gix_hash::oid, | ||
| 306 | find: Find, | ||
| 307 | } | ||
| 308 | |||
| 309 | impl<'a> content::ReadDataOnce<'a, Error> for WorktreeBlob<'a> { | ||
| 310 | fn read_data(self) -> Result<&'a [u8], Error> { | ||
| 311 | let res = read::data_to_buf_with_meta( | ||
| 312 | self.path, | ||
| 313 | self.buf, | ||
| 314 | self.entry.mode == gix_index::entry::Mode::SYMLINK, | ||
| 315 | &self.options.fs, | ||
| 316 | )?; | ||
| 317 | Ok(res) | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | impl<'a, Find, E> content::ReadDataOnce<'a, Error> for OdbBlob<'a, Find, E> | ||
| 322 | where | ||
| 323 | E: std::error::Error + Send + Sync + 'static, | ||
| 324 | Find: FnMut(&gix_hash::oid, &'a mut Vec<u8>) -> Result<gix_object::BlobRef<'a>, E>, | ||
| 325 | { | ||
| 326 | fn read_data(mut self) -> Result<&'a [u8], Error> { | ||
| 327 | (self.find)(self.id, self.buf) | ||
| 328 | .map(|b| b.data) | ||
| 329 | .map_err(move |err| Error::Find(Box::new(err))) | ||
| 330 | } | ||
| 331 | } | ||
| @@ -1,11 +0,0 @@ | |||
| 1 | //! Changes between an index and a worktree. | ||
| 2 | /// | ||
| 3 | mod types; | ||
| 4 | pub use types::{Change, Error, Options, VisitEntry}; | ||
| 5 | |||
| 6 | mod recorder; | ||
| 7 | pub use recorder::Recorder; | ||
| 8 | |||
| 9 | /// | ||
| 10 | pub mod content; | ||
| 11 | pub(crate) mod function; | ||
| @@ -1,27 +0,0 @@ | |||
| 1 | use bstr::BStr; | ||
| 2 | use gix_index as index; | ||
| 3 | |||
| 4 | use crate::status::{Change, VisitEntry}; | ||
| 5 | |||
| 6 | /// Convenience implementation of [`VisitEntry`] that collects all non-trivial changes into a `Vec`. | ||
| 7 | #[derive(Debug, Default)] | ||
| 8 | pub struct Recorder<'index, T = ()> { | ||
| 9 | /// collected changes, index entries without conflicts or changes are excluded. | ||
| 10 | pub records: Vec<(&'index BStr, Option<Change<T>>, bool)>, | ||
| 11 | } | ||
| 12 | |||
| 13 | impl<'index, T: Send> VisitEntry<'index> for Recorder<'index, T> { | ||
| 14 | type ContentChange = T; | ||
| 15 | |||
| 16 | fn visit_entry( | ||
| 17 | &mut self, | ||
| 18 | _entry: &'index index::Entry, | ||
| 19 | rela_path: &'index BStr, | ||
| 20 | status: Option<Change<Self::ContentChange>>, | ||
| 21 | conflict: bool, | ||
| 22 | ) { | ||
| 23 | if conflict || status.is_some() { | ||
| 24 | self.records.push((rela_path, status, conflict)) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| @@ -1,69 +0,0 @@ | |||
| 1 | use bstr::BStr; | ||
| 2 | |||
| 3 | /// The error returned by [`status()`][crate::status()]. | ||
| 4 | #[derive(Debug, thiserror::Error)] | ||
| 5 | #[allow(missing_docs)] | ||
| 6 | pub enum Error { | ||
| 7 | #[error("Could not convert path to UTF8")] | ||
| 8 | IllformedUtf8, | ||
| 9 | #[error("The clock was off when reading file related metadata after updating a file on disk")] | ||
| 10 | Time(#[from] std::time::SystemTimeError), | ||
| 11 | #[error("IO error while writing blob or reading file metadata or changing filetype")] | ||
| 12 | Io(#[from] std::io::Error), | ||
| 13 | #[error("Failed to obtain blob from object database")] | ||
| 14 | Find(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Clone, Default)] | ||
| 18 | /// Options that control how the index status with a worktree is computed. | ||
| 19 | pub struct Options { | ||
| 20 | /// Capabilities of the file system which affect the status computation. | ||
| 21 | pub fs: gix_fs::Capabilities, | ||
| 22 | /// If set, don't use more than this amount of threads. | ||
| 23 | /// Otherwise, usually use as many threads as there are logical cores. | ||
| 24 | /// A value of 0 is interpreted as no-limit | ||
| 25 | pub thread_limit: Option<usize>, | ||
| 26 | /// Options that control how stat comparisons are made when checking if a file is fresh. | ||
| 27 | pub stat: gix_index::entry::stat::Options, | ||
| 28 | } | ||
| 29 | |||
| 30 | /// How an index entry needs to be changed to obtain the destination worktree state, i.e. `entry.apply(this_change) == worktree-entry`. | ||
| 31 | #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] | ||
| 32 | pub enum Change<T = ()> { | ||
| 33 | /// This corresponding file does not exist in the worktree anymore. | ||
| 34 | Removed, | ||
| 35 | /// The type of file changed compared to the worktree, i.e. a symlink s now a file. | ||
| 36 | Type, | ||
| 37 | /// This worktree file was modified in some form, like a permission change or content change or both, | ||
| 38 | /// as compared to this entry. | ||
| 39 | Modification { | ||
| 40 | /// Indicates that one of the stat changes was an executable bit change | ||
| 41 | /// which is a significant change itself. | ||
| 42 | executable_bit_changed: bool, | ||
| 43 | /// The output of the [`CompareBlobs`][crate::status::content::CompareBlobs] run on this entry. | ||
| 44 | /// If there is no content change and only the executable bit | ||
| 45 | /// changed than this is `None`. | ||
| 46 | content_change: Option<T>, | ||
| 47 | }, | ||
| 48 | /// An index entry that correspond to an untracked worktree file marked with `git add --intent-to-add`. | ||
| 49 | /// | ||
| 50 | /// This means it's not available in the object database yet or the index was created from, | ||
| 51 | /// even though now an entry exists that represents the worktree file. | ||
| 52 | IntentToAdd, | ||
| 53 | } | ||
| 54 | |||
| 55 | /// Observe changes by comparing an index entry to the worktree or another index. | ||
| 56 | pub trait VisitEntry<'index> { | ||
| 57 | /// Data generated by comparing an entry with a file. | ||
| 58 | type ContentChange; | ||
| 59 | /// Observe the `change` of `entry` at the repository-relative `rela_path`, indicating whether | ||
| 60 | /// or not it has a `conflict`. | ||
| 61 | /// If `change` is `None`, there is no change. | ||
| 62 | fn visit_entry( | ||
| 63 | &mut self, | ||
| 64 | entry: &'index gix_index::Entry, | ||
| 65 | rela_path: &'index BStr, | ||
| 66 | change: Option<Change<Self::ContentChange>>, | ||
| 67 | conflict: bool, | ||
| 68 | ); | ||
| 69 | } | ||
| @@ -1 +0,0 @@ | |||
| 1 | // TODO: untracked file detection, needs fs::Cache | ||
| @@ -1,7 +1,2 @@ | |||
| 1 | make_ignore_and_attributes_setup.tar.xz | 1 | make_ignore_and_attributes_setup.tar.xz |
| 2 | make_mixed_without_submodules.tar.xz | ||
| 3 | make_mixed_without_submodules_and_symlinks.tar.xz | ||
| 4 | make_attributes_baseline.tar.xz | 2 | make_attributes_baseline.tar.xz |
| 5 | make_dangerous_symlink.tar.xz | ||
| 6 | status_unchanged.tar.xz | ||
| 7 | status_changed.tar.xz | ||
| @@ -1,3 +0,0 @@ | |||
| 1 | version https://git-lfs.github.com/spec/v1 | ||
| 2 | oid sha256:2984e2e61b28635014165351cd872ea7e4f09c28b8b4bbe50692a465ef648033 | ||
| 3 | size 10616 | ||
| @@ -1,3 +1,3 @@ | |||
| 1 | version https://git-lfs.github.com/spec/v1 | 1 | version https://git-lfs.github.com/spec/v1 |
| 2 | oid sha256:24f605623efc49819d1b30c52fe22da8f94f2d267e8030ec9bc3b9b845801f76 | 2 | oid sha256:1804dc740055b8a5afe65a2db14f29c8ae4691896e67342a8dcb11530fd448c6 |
| 3 | size 9220 | 3 | size 9240 |
| @@ -1,3 +0,0 @@ | |||
| 1 | version https://git-lfs.github.com/spec/v1 | ||
| 2 | oid sha256:35b728a127f3b6170bac44469ff36d5ad0be2a4247a8926f1aaffb97b5973efc | ||
| 3 | size 1596 | ||
| @@ -1,3 +0,0 @@ | |||
| 1 | version https://git-lfs.github.com/spec/v1 | ||
| 2 | oid sha256:cd6d32ab7a1e372d80a617926cac2463f6620baedf74642d78fe7f8c956fd031 | ||
| 3 | size 11036 | ||
| @@ -1,3 +0,0 @@ | |||
| 1 | version https://git-lfs.github.com/spec/v1 | ||
| 2 | oid sha256:876670d74c01249d361aa73d83ab20d846db7c922a3ca825f778b5f9d746c401 | ||
| 3 | size 9304 | ||
| @@ -1,3 +0,0 @@ | |||
| 1 | version https://git-lfs.github.com/spec/v1 | ||
| 2 | oid sha256:e5fe85a65e3689e9e60598130be60761dc4ea129e04d7d5501320f7ebad1eb2b | ||
| 3 | size 10520 | ||
| @@ -1,24 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | # Every symlink is dangerous as it might either link to another directory and thus redirect | ||
| 7 | # all writes in the path, or it might point to a file and opening the symlink actually opens | ||
| 8 | # the target. | ||
| 9 | # We handle this by either validating symlinks specifically or create symlinks | ||
| 10 | empty_oid=$(git hash-object -w --stdin </dev/null) | ||
| 11 | fake_dir_target=$(echo -n 'A-dir' | git hash-object -w --stdin) | ||
| 12 | fake_file_target=$(echo -n 'A-file' | git hash-object -w --stdin) | ||
| 13 | |||
| 14 | git update-index --index-info <<-EOF | ||
| 15 | 100644 $empty_oid A-dir/a | ||
| 16 | 100644 $empty_oid A-file | ||
| 17 | 120000 $fake_dir_target FAKE-DIR | ||
| 18 | 120000 $fake_file_target FAKE-FILE | ||
| 19 | 100644 $empty_oid fake-dir/b | ||
| 20 | 100644 $empty_oid fake-file | ||
| 21 | EOF | ||
| 22 | |||
| 23 | git commit -m "init" | ||
| 24 | git checkout -f HEAD; | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | cat <<EOF >.git/info/exclude | ||
| 7 | # a sample .git/info/exclude | ||
| 8 | file-anywhere | ||
| 9 | /file-from-top | ||
| 10 | |||
| 11 | dir-anywhere/ | ||
| 12 | /dir-from-top | ||
| 13 | |||
| 14 | subdir-anywhere/file | ||
| 15 | subdir-anywhere/dir/ | ||
| 16 | EOF | ||
| 17 | |||
| 18 | git commit --allow-empty -m "init" | ||
| @@ -1,27 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | empty_oid=$(git hash-object -w --stdin </dev/null) | ||
| 7 | content_oid=$(echo "content" | git hash-object -w --stdin) | ||
| 8 | symlink_target=$(echo -n 'X' | git hash-object -w --stdin) | ||
| 9 | |||
| 10 | echo "FILE_? filter=arrow" > .gitattributes | ||
| 11 | git add -A | ||
| 12 | |||
| 13 | git update-index --index-info <<-EOF | ||
| 14 | 100644 $content_oid FILE_X | ||
| 15 | 100644 $content_oid FILE_x | ||
| 16 | 100644 $content_oid file_X | ||
| 17 | 100644 $content_oid file_x | ||
| 18 | 100644 $empty_oid D/B | ||
| 19 | 100644 $empty_oid D/C | ||
| 20 | 100644 $empty_oid d | ||
| 21 | 100644 $empty_oid X | ||
| 22 | 120000 $symlink_target x | ||
| 23 | 120000 $symlink_target link-to-X | ||
| 24 | EOF | ||
| 25 | |||
| 26 | git commit -m "init" | ||
| 27 | git checkout -f HEAD; | ||
| @@ -1,20 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | touch empty | ||
| 7 | echo -n "content" > executable | ||
| 8 | chmod +x executable | ||
| 9 | |||
| 10 | mkdir dir | ||
| 11 | echo "other content" > dir/content | ||
| 12 | echo "* filter=arrow" > .gitattributes | ||
| 13 | echo "executable -filter" >> .gitattributes | ||
| 14 | echo ".gitattributes -filter" >> .gitattributes | ||
| 15 | |||
| 16 | mkdir dir/sub-dir | ||
| 17 | (cd dir/sub-dir && ln -sf ../content symlink) | ||
| 18 | |||
| 19 | git add -A | ||
| 20 | git commit -m "Commit" | ||
| @@ -1,20 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | touch empty | ||
| 7 | echo -n "content" > executable | ||
| 8 | chmod +x executable | ||
| 9 | |||
| 10 | mkdir dir | ||
| 11 | echo "other content" > dir/content | ||
| 12 | echo "* filter=arrow" > .gitattributes | ||
| 13 | echo "executable -filter" >> .gitattributes | ||
| 14 | echo ".gitattributes -filter" >> .gitattributes | ||
| 15 | |||
| 16 | mkdir dir/sub-dir | ||
| 17 | echo "even other content" > dir/sub-dir/file | ||
| 18 | |||
| 19 | git add -A | ||
| 20 | git commit -m "Commit" | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | echo -n "foo" > content | ||
| 7 | |||
| 8 | git add -A | ||
| 9 | git commit -m "Commit" | ||
| 10 | |||
| 11 | # file size should not be changed by this | ||
| 12 | echo -n "bar" > content | ||
| @@ -1,26 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | touch empty | ||
| 7 | echo -n "content" > executable | ||
| 8 | chmod +x executable | ||
| 9 | |||
| 10 | mkdir dir | ||
| 11 | echo -n "other content" > dir/content | ||
| 12 | echo -n "other content" > dir/content2 | ||
| 13 | mkdir dir/sub-dir | ||
| 14 | (cd dir/sub-dir && ln -sf ../content symlink) | ||
| 15 | |||
| 16 | git add -A | ||
| 17 | git commit -m "Commit" | ||
| 18 | |||
| 19 | chmod +x dir/content | ||
| 20 | echo "new content" > dir/content2 | ||
| 21 | chmod -x executable | ||
| 22 | echo -n "foo" > executable | ||
| 23 | |||
| 24 | rm empty | ||
| 25 | ln -sf dir/content empty | ||
| 26 | git reset | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | echo base > content | ||
| 7 | git add -A | ||
| 8 | git commit -m "base" | ||
| 9 | |||
| 10 | git checkout -b feat | ||
| 11 | echo feat > content | ||
| 12 | git commit -am "feat" | ||
| 13 | |||
| 14 | git checkout main | ||
| 15 | echo base-change > content | ||
| 16 | git commit -am "new base" | ||
| 17 | |||
| 18 | git merge feat || : | ||
| @@ -1,9 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | touch content | ||
| 7 | echo -n "content" > content | ||
| 8 | |||
| 9 | git add --intent-to-add -A | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | touch empty | ||
| 7 | echo -n "content" > executable | ||
| 8 | chmod +x executable | ||
| 9 | |||
| 10 | mkdir dir | ||
| 11 | echo -n "other content" > dir/content | ||
| 12 | mkdir dir/sub-dir | ||
| 13 | (cd dir/sub-dir && ln -sf ../content symlink) | ||
| 14 | |||
| 15 | git add -A | ||
| 16 | git commit -m "Commit" | ||
| 17 | rm -rf ./empty ./executable ./dir/content ./dir/sub-dir/symlink | ||
| 18 | git reset | ||
| @@ -1,20 +0,0 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | set -eu -o pipefail | ||
| 3 | |||
| 4 | git init -q | ||
| 5 | |||
| 6 | touch empty | ||
| 7 | echo -n "content" > executable | ||
| 8 | chmod +x executable | ||
| 9 | |||
| 10 | mkdir dir | ||
| 11 | echo -n "other content" > dir/content | ||
| 12 | mkdir dir/sub-dir | ||
| 13 | (cd dir/sub-dir && ln -sf ../content symlink) | ||
| 14 | |||
| 15 | git add -A | ||
| 16 | git commit -m "Commit" | ||
| 17 | |||
| 18 | touch ./empty ./executable ./dir/content ./dir/sub-dir/symlink | ||
| 19 | |||
| 20 | git reset # ensure index timestamp is large enough to not mark everything racy | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | use std::path::Path; | 1 | use std::path::Path; |
| 2 | 2 | ||
| 3 | use gix_testtools::tempfile::{tempdir, TempDir}; | ||
| 3 | use gix_worktree::{cache, Cache}; | 4 | use gix_worktree::{cache, Cache}; |
| 4 | use tempfile::{tempdir, TempDir}; | ||
| 5 | 5 | ||
| 6 | #[allow(clippy::ptr_arg)] | 6 | #[allow(clippy::ptr_arg)] |
| 7 | fn panic_on_find<'buf>(_oid: &gix_hash::oid, _buf: &'buf mut Vec<u8>) -> std::io::Result<gix_object::BlobRef<'buf>> { | 7 | fn panic_on_find<'buf>(_oid: &gix_hash::oid, _buf: &'buf mut Vec<u8>) -> std::io::Result<gix_object::BlobRef<'buf>> { |
| @@ -1,526 +0,0 @@ | |||
| 1 | #[cfg(unix)] | ||
| 2 | use std::os::unix::prelude::MetadataExt; | ||
| 3 | use std::{ | ||
| 4 | fs, | ||
| 5 | io::{ErrorKind, ErrorKind::AlreadyExists}, | ||
| 6 | path::{Path, PathBuf}, | ||
| 7 | sync::atomic::{AtomicBool, AtomicUsize, Ordering}, | ||
| 8 | }; | ||
| 9 | |||
| 10 | use gix_features::progress; | ||
| 11 | use gix_object::bstr::ByteSlice; | ||
| 12 | use gix_odb::FindExt; | ||
| 13 | use gix_worktree::checkout::Collision; | ||
| 14 | use once_cell::sync::Lazy; | ||
| 15 | use tempfile::TempDir; | ||
| 16 | |||
| 17 | use crate::fixture_path; | ||
| 18 | |||
| 19 | static DRIVER: Lazy<PathBuf> = Lazy::new(|| { | ||
| 20 | let mut cargo = std::process::Command::new(env!("CARGO")); | ||
| 21 | let res = cargo | ||
| 22 | .args(["build", "-p=gix-filter", "--example", "arrow"]) | ||
| 23 | .status() | ||
| 24 | .expect("cargo should run fine"); | ||
| 25 | assert!(res.success(), "cargo invocation should be successful"); | ||
| 26 | |||
| 27 | let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) | ||
| 28 | .ancestors() | ||
| 29 | .nth(1) | ||
| 30 | .expect("first parent in target dir") | ||
| 31 | .join("debug") | ||
| 32 | .join("examples") | ||
| 33 | .join(if cfg!(windows) { "arrow.exe" } else { "arrow" }); | ||
| 34 | assert!(path.is_file(), "Expecting driver to be located at {path:?}"); | ||
| 35 | path | ||
| 36 | }); | ||
| 37 | |||
| 38 | fn driver_exe() -> String { | ||
| 39 | let mut exe = DRIVER.to_string_lossy().into_owned(); | ||
| 40 | if cfg!(windows) { | ||
| 41 | exe = exe.replace('\\', "/"); | ||
| 42 | } | ||
| 43 | exe | ||
| 44 | } | ||
| 45 | |||
| 46 | #[test] | ||
| 47 | fn accidental_writes_through_symlinks_are_prevented_if_overwriting_is_forbidden() { | ||
| 48 | let mut opts = opts_from_probe(); | ||
| 49 | // without overwrite mode, everything is safe. | ||
| 50 | opts.overwrite_existing = false; | ||
| 51 | let (source_tree, destination, _index, outcome) = | ||
| 52 | checkout_index_in_tmp_dir(opts.clone(), "make_dangerous_symlink").unwrap(); | ||
| 53 | |||
| 54 | let source_files = dir_structure(&source_tree); | ||
| 55 | let worktree_files = dir_structure(&destination); | ||
| 56 | |||
| 57 | if opts.fs.ignore_case { | ||
| 58 | assert_eq!( | ||
| 59 | stripped_prefix(&source_tree, &source_files), | ||
| 60 | stripped_prefix(&destination, &worktree_files), | ||
| 61 | ); | ||
| 62 | if multi_threaded() { | ||
| 63 | assert_eq!(outcome.collisions.len(), 2); | ||
| 64 | } else { | ||
| 65 | assert_eq!( | ||
| 66 | outcome.collisions, | ||
| 67 | vec![ | ||
| 68 | Collision { | ||
| 69 | path: "FAKE-DIR".into(), | ||
| 70 | error_kind: AlreadyExists | ||
| 71 | }, | ||
| 72 | Collision { | ||
| 73 | path: "FAKE-FILE".into(), | ||
| 74 | error_kind: AlreadyExists | ||
| 75 | } | ||
| 76 | ] | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | } else { | ||
| 80 | let expected = ["A-dir/a", "A-file", "FAKE-DIR", "FAKE-FILE", "fake-dir/b", "fake-file"]; | ||
| 81 | assert_eq!(stripped_prefix(&source_tree, &source_files), paths(expected)); | ||
| 82 | assert_eq!(stripped_prefix(&destination, &worktree_files), paths(expected)); | ||
| 83 | assert!(outcome.collisions.is_empty()); | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | #[test] | ||
| 88 | fn writes_through_symlinks_are_prevented_even_if_overwriting_is_allowed() { | ||
| 89 | let mut opts = opts_from_probe(); | ||
| 90 | // with overwrite mode | ||
| 91 | opts.overwrite_existing = true; | ||
| 92 | let (source_tree, destination, _index, outcome) = | ||
| 93 | checkout_index_in_tmp_dir(opts.clone(), "make_dangerous_symlink").unwrap(); | ||
| 94 | |||
| 95 | let source_files = dir_structure(&source_tree); | ||
| 96 | let worktree_files = dir_structure(&destination); | ||
| 97 | |||
| 98 | if opts.fs.ignore_case { | ||
| 99 | assert_eq!( | ||
| 100 | stripped_prefix(&source_tree, &source_files), | ||
| 101 | paths(["A-dir/a", "A-file", "fake-dir/b", "fake-file"]), | ||
| 102 | ); | ||
| 103 | assert_eq!( | ||
| 104 | stripped_prefix(&destination, &worktree_files), | ||
| 105 | paths(["A-dir/a", "A-file", "FAKE-DIR", "FAKE-FILE"]), | ||
| 106 | ); | ||
| 107 | assert!(outcome.collisions.is_empty()); | ||
| 108 | } else { | ||
| 109 | let expected = ["A-dir/a", "A-file", "FAKE-DIR", "FAKE-FILE", "fake-dir/b", "fake-file"]; | ||
| 110 | assert_eq!(stripped_prefix(&source_tree, &source_files), paths(expected)); | ||
| 111 | assert_eq!(stripped_prefix(&destination, &worktree_files), paths(expected)); | ||
| 112 | assert!(outcome.collisions.is_empty()); | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | #[test] | ||
| 117 | fn delayed_driver_process() -> crate::Result { | ||
| 118 | let mut opts = opts_from_probe(); | ||
| 119 | opts.overwrite_existing = true; | ||
| 120 | opts.filter_process_delay = gix_filter::driver::apply::Delay::Allow; | ||
| 121 | opts.destination_is_initially_empty = false; | ||
| 122 | setup_filter_pipeline(opts.filters.options_mut()); | ||
| 123 | let (_source, destination, _index, outcome) = | ||
| 124 | checkout_index_in_tmp_dir_opts(opts, "make_mixed_without_submodules_and_symlinks", |_| true, |_| Ok(()))?; | ||
| 125 | assert_eq!(outcome.collisions.len(), 0); | ||
| 126 | assert_eq!(outcome.errors.len(), 0); | ||
| 127 | assert_eq!(outcome.files_updated, 5); | ||
| 128 | |||
| 129 | let dest = destination.path(); | ||
| 130 | assert_eq!( | ||
| 131 | std::fs::read(dest.join("executable"))?.as_bstr(), | ||
| 132 | "content", | ||
| 133 | "unfiltered" | ||
| 134 | ); | ||
| 135 | assert_eq!( | ||
| 136 | std::fs::read(dest.join("dir").join("content"))?.as_bstr(), | ||
| 137 | "➡other content\r\n" | ||
| 138 | ); | ||
| 139 | assert_eq!( | ||
| 140 | std::fs::read(dest.join("dir").join("sub-dir").join("file"))?.as_bstr(), | ||
| 141 | "➡even other content\r\n" | ||
| 142 | ); | ||
| 143 | Ok(()) | ||
| 144 | } | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | #[cfg_attr( | ||
| 148 | windows, | ||
| 149 | ignore = "on windows, the symlink to a directory doesn't seem to work and we really want to test with symlinks" | ||
| 150 | )] | ||
| 151 | fn overwriting_files_and_lone_directories_works() -> crate::Result { | ||
| 152 | for delay in [ | ||
| 153 | gix_filter::driver::apply::Delay::Allow, | ||
| 154 | gix_filter::driver::apply::Delay::Forbid, | ||
| 155 | ] { | ||
| 156 | let mut opts = opts_from_probe(); | ||
| 157 | opts.overwrite_existing = true; | ||
| 158 | opts.filter_process_delay = delay; | ||
| 159 | opts.destination_is_initially_empty = false; | ||
| 160 | setup_filter_pipeline(opts.filters.options_mut()); | ||
| 161 | let (source, destination, _index, outcome) = checkout_index_in_tmp_dir_opts( | ||
| 162 | opts.clone(), | ||
| 163 | "make_mixed_without_submodules", | ||
| 164 | |_| true, | ||
| 165 | |d| { | ||
| 166 | let empty = d.join("empty"); | ||
| 167 | symlink::symlink_dir(d.join(".."), &empty)?; // empty is symlink to the directory above | ||
| 168 | std::fs::write(d.join("executable"), b"foo")?; // executable is regular file and has different content | ||
| 169 | let dir = d.join("dir"); | ||
| 170 | std::fs::create_dir(&dir)?; | ||
| 171 | std::fs::create_dir(dir.join("content"))?; // 'content' is a directory now | ||
| 172 | |||
| 173 | let dir = dir.join("sub-dir"); | ||
| 174 | std::fs::create_dir(&dir)?; | ||
| 175 | |||
| 176 | symlink::symlink_dir(empty, dir.join("symlink"))?; // 'symlink' is a symlink to another file | ||
| 177 | Ok(()) | ||
| 178 | }, | ||
| 179 | )?; | ||
| 180 | |||
| 181 | assert!(outcome.collisions.is_empty()); | ||
| 182 | |||
| 183 | assert_eq!( | ||
| 184 | stripped_prefix(&destination, &dir_structure(&destination)), | ||
| 185 | paths(["dir/content", "dir/sub-dir/symlink", "empty", "executable"]) | ||
| 186 | ); | ||
| 187 | let meta = std::fs::symlink_metadata(destination.path().join("empty"))?; | ||
| 188 | assert!(meta.is_file(), "'empty' is now a file"); | ||
| 189 | assert_eq!(meta.len(), 0, "'empty' is indeed empty"); | ||
| 190 | |||
| 191 | let exe = destination.path().join("executable"); | ||
| 192 | assert_eq!(std::fs::read(&exe)?, b"content", "'exe' has the correct content"); | ||
| 193 | |||
| 194 | let meta = std::fs::symlink_metadata(exe)?; | ||
| 195 | assert!(meta.is_file()); | ||
| 196 | if opts.fs.executable_bit { | ||
| 197 | #[cfg(unix)] | ||
| 198 | assert_eq!(meta.mode() & 0o700, 0o700, "the executable bit is set where supported"); | ||
| 199 | } | ||
| 200 | |||
| 201 | assert_eq!( | ||
| 202 | std::fs::read(source.join("dir/content"))?.as_bstr(), | ||
| 203 | "other content\n", | ||
| 204 | "in the worktree, we have LF" | ||
| 205 | ); | ||
| 206 | assert_eq!( | ||
| 207 | std::fs::read(destination.path().join("dir/content"))?.as_bstr(), | ||
| 208 | "➡other content\r\n", | ||
| 209 | "autocrlf is enabled, so we get CRLF when checking out as the pipeline is active, and we have a filter" | ||
| 210 | ); | ||
| 211 | |||
| 212 | let symlink = destination.path().join("dir/sub-dir/symlink"); | ||
| 213 | // on windows, git won't create symlinks as its probe won't detect the capability, even though we do. | ||
| 214 | assert_eq!(std::fs::symlink_metadata(&symlink)?.is_symlink(), cfg!(unix)); | ||
| 215 | assert_eq!( | ||
| 216 | std::fs::read(symlink)?.as_bstr(), | ||
| 217 | "➡other content\r\n", | ||
| 218 | "autocrlf is enabled" | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | Ok(()) | ||
| 222 | } | ||
| 223 | |||
| 224 | #[test] | ||
| 225 | fn symlinks_become_files_if_disabled() -> crate::Result { | ||
| 226 | let mut opts = opts_from_probe(); | ||
| 227 | opts.fs.symlink = false; | ||
| 228 | let (source_tree, destination, _index, outcome) = | ||
| 229 | checkout_index_in_tmp_dir(opts.clone(), "make_mixed_without_submodules")?; | ||
| 230 | |||
| 231 | assert_equality(&source_tree, &destination, opts.fs.symlink)?; | ||
| 232 | assert!(outcome.collisions.is_empty()); | ||
| 233 | Ok(()) | ||
| 234 | } | ||
| 235 | |||
| 236 | #[test] | ||
| 237 | fn allow_or_disallow_symlinks() -> crate::Result { | ||
| 238 | let mut opts = opts_from_probe(); | ||
| 239 | for allowed in &[false, true] { | ||
| 240 | opts.fs.symlink = *allowed; | ||
| 241 | let (source_tree, destination, _index, outcome) = | ||
| 242 | checkout_index_in_tmp_dir(opts.clone(), "make_mixed_without_submodules")?; | ||
| 243 | |||
| 244 | assert_equality(&source_tree, &destination, opts.fs.symlink)?; | ||
| 245 | assert!(outcome.collisions.is_empty()); | ||
| 246 | } | ||
| 247 | Ok(()) | ||
| 248 | } | ||
| 249 | |||
| 250 | #[test] | ||
| 251 | fn keep_going_collects_results() { | ||
| 252 | let mut opts = opts_from_probe(); | ||
| 253 | opts.keep_going = true; | ||
| 254 | let count = AtomicUsize::default(); | ||
| 255 | let (_source_tree, destination, _index, outcome) = checkout_index_in_tmp_dir_opts( | ||
| 256 | opts, | ||
| 257 | "make_mixed_without_submodules", | ||
| 258 | |_id| { | ||
| 259 | !matches!( | ||
| 260 | count.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { | ||
| 261 | (current < 2).then_some(current + 1) | ||
| 262 | }), | ||
| 263 | Ok(_) | ||
| 264 | ) | ||
| 265 | }, | ||
| 266 | |_| Ok(()), | ||
| 267 | ) | ||
| 268 | .unwrap(); | ||
| 269 | |||
| 270 | if multi_threaded() { | ||
| 271 | assert_eq!( | ||
| 272 | outcome.errors.len(), | ||
| 273 | 2, | ||
| 274 | "content changes due to non-deterministic nature of racy threads" | ||
| 275 | ) | ||
| 276 | } else { | ||
| 277 | assert_eq!( | ||
| 278 | outcome | ||
| 279 | .errors | ||
| 280 | .iter() | ||
| 281 | .map(|r| r.path.to_path_lossy().into_owned()) | ||
| 282 | .collect::<Vec<_>>(), | ||
| 283 | paths(if cfg!(unix) { | ||
| 284 | [".gitattributes", "dir/content"] | ||
| 285 | } else { | ||
| 286 | // not actually a symlink anymore, even though symlinks are supported but git think differently. | ||
| 287 | ["dir/content", "dir/sub-dir/symlink"] | ||
| 288 | }) | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | |||
| 292 | if multi_threaded() { | ||
| 293 | let actual = dir_structure(&destination); | ||
| 294 | assert!( | ||
| 295 | (2..=3).contains(&actual.len()), | ||
| 296 | "it's 3 most of the time, but can be 2 of the 'empty' file is missing as the object couldn't be accessed.\ | ||
| 297 | It's unclear why there isn't more, as it would keep going" | ||
| 298 | ); | ||
| 299 | } else { | ||
| 300 | assert_eq!( | ||
| 301 | stripped_prefix(&destination, &dir_structure(&destination)), | ||
| 302 | paths(if cfg!(unix) { | ||
| 303 | Box::new(["dir/sub-dir/symlink", "empty", "executable"].into_iter()) as Box<dyn Iterator<Item = &str>> | ||
| 304 | } else { | ||
| 305 | Box::new(["empty", "executable"].into_iter()) | ||
| 306 | }), | ||
| 307 | "some files could not be created" | ||
| 308 | ); | ||
| 309 | } | ||
| 310 | |||
| 311 | assert!(outcome.collisions.is_empty()); | ||
| 312 | } | ||
| 313 | |||
| 314 | #[test] | ||
| 315 | fn no_case_related_collisions_on_case_sensitive_filesystem() { | ||
| 316 | let opts = opts_from_probe(); | ||
| 317 | if opts.fs.ignore_case { | ||
| 318 | eprintln!("Skipping case-sensitive testing on what would be a case-insensitive file system"); | ||
| 319 | return; | ||
| 320 | } | ||
| 321 | let (source_tree, destination, index, outcome) = | ||
| 322 | checkout_index_in_tmp_dir(opts.clone(), "make_ignorecase_collisions").unwrap(); | ||
| 323 | |||
| 324 | assert!(outcome.collisions.is_empty()); | ||
| 325 | let num_files = assert_equality(&source_tree, &destination, opts.fs.symlink).unwrap(); | ||
| 326 | assert_eq!( | ||
| 327 | num_files, | ||
| 328 | index.entries().len() - 1, | ||
| 329 | "it checks out all files (minus 1 to account for .gitattributes which is skipped in the worktree in our tests)" | ||
| 330 | ); | ||
| 331 | assert!( | ||
| 332 | destination.path().join(".gitattributes").is_file(), | ||
| 333 | "we do have attributes even though, dot files are ignored in `assert_equality`" | ||
| 334 | ); | ||
| 335 | } | ||
| 336 | |||
| 337 | #[test] | ||
| 338 | fn collisions_are_detected_on_a_case_insensitive_filesystem_even_with_delayed_filters() { | ||
| 339 | let mut opts = opts_from_probe(); | ||
| 340 | if !opts.fs.ignore_case { | ||
| 341 | eprintln!("Skipping case-insensitive testing on what would be a case-sensitive file system"); | ||
| 342 | return; | ||
| 343 | } | ||
| 344 | setup_filter_pipeline(opts.filters.options_mut()); | ||
| 345 | opts.filter_process_delay = gix_filter::driver::apply::Delay::Allow; | ||
| 346 | let (source_tree, destination, _index, outcome) = | ||
| 347 | checkout_index_in_tmp_dir(opts, "make_ignorecase_collisions").unwrap(); | ||
| 348 | |||
| 349 | let source_files = dir_structure(&source_tree); | ||
| 350 | assert_eq!( | ||
| 351 | stripped_prefix(&source_tree, &source_files), | ||
| 352 | paths(["d", "file_x", "link-to-X", "x"]), | ||
| 353 | "plenty of collisions prevent a checkout" | ||
| 354 | ); | ||
| 355 | |||
| 356 | let dest_files = dir_structure(&destination); | ||
| 357 | if multi_threaded() { | ||
| 358 | assert!( | ||
| 359 | (4..=6).contains(&dest_files.len()), | ||
| 360 | "due to the clash happening at nearly any time, and keep-going is false, we get a variance of files" | ||
| 361 | ); | ||
| 362 | } else { | ||
| 363 | assert_eq!( | ||
| 364 | stripped_prefix(&destination, &dest_files), | ||
| 365 | paths(["D/B", "D/C", "FILE_X", "X", "link-to-X"]), | ||
| 366 | "we checkout files in order and generally handle collision detection differently, hence the difference" | ||
| 367 | ); | ||
| 368 | } | ||
| 369 | |||
| 370 | let error_kind = ErrorKind::AlreadyExists; | ||
| 371 | #[cfg(windows)] | ||
| 372 | let error_kind_dir = ErrorKind::PermissionDenied; | ||
| 373 | #[cfg(not(windows))] | ||
| 374 | let error_kind_dir = error_kind; | ||
| 375 | |||
| 376 | if multi_threaded() { | ||
| 377 | assert!( | ||
| 378 | (5..=6).contains(&outcome.collisions.len()), | ||
| 379 | "can only assert on number as it's racily creating files so unclear which one clashes, and due to keep-going = false there is variance" | ||
| 380 | ); | ||
| 381 | } else { | ||
| 382 | assert_eq!( | ||
| 383 | outcome.collisions, | ||
| 384 | vec![ | ||
| 385 | Collision { | ||
| 386 | path: "d".into(), | ||
| 387 | error_kind: error_kind_dir, | ||
| 388 | }, | ||
| 389 | Collision { | ||
| 390 | path: "FILE_x".into(), | ||
| 391 | error_kind, | ||
| 392 | }, | ||
| 393 | Collision { | ||
| 394 | path: "file_X".into(), | ||
| 395 | error_kind, | ||
| 396 | }, | ||
| 397 | Collision { | ||
| 398 | path: "file_x".into(), | ||
| 399 | error_kind, | ||
| 400 | }, | ||
| 401 | Collision { | ||
| 402 | path: "x".into(), | ||
| 403 | error_kind, | ||
| 404 | }, | ||
| 405 | ], | ||
| 406 | "these files couldn't be checked out" | ||
| 407 | ); | ||
| 408 | } | ||
| 409 | } | ||
| 410 | |||
| 411 | fn multi_threaded() -> bool { | ||
| 412 | gix_features::parallel::num_threads(None) > 1 | ||
| 413 | } | ||
| 414 | |||
| 415 | fn assert_equality(source_tree: &Path, destination: &TempDir, allow_symlinks: bool) -> crate::Result<usize> { | ||
| 416 | let source_files = dir_structure(source_tree); | ||
| 417 | let worktree_files = dir_structure(destination); | ||
| 418 | |||
| 419 | assert_eq!( | ||
| 420 | stripped_prefix(source_tree, &source_files), | ||
| 421 | stripped_prefix(destination, &worktree_files), | ||
| 422 | ); | ||
| 423 | |||
| 424 | let mut count = 0; | ||
| 425 | for (source_file, worktree_file) in source_files.iter().zip(worktree_files.iter()) { | ||
| 426 | count += 1; | ||
| 427 | if !allow_symlinks && source_file.is_symlink() { | ||
| 428 | assert!(!worktree_file.is_symlink()); | ||
| 429 | assert_eq!(fs::read(worktree_file)?.to_path()?, fs::read_link(source_file)?); | ||
| 430 | } else { | ||
| 431 | assert_eq!(fs::read(source_file)?, fs::read(worktree_file)?); | ||
| 432 | #[cfg(unix)] | ||
| 433 | assert_eq!( | ||
| 434 | fs::symlink_metadata(source_file)?.mode() & 0o700, | ||
| 435 | fs::symlink_metadata(worktree_file)?.mode() & 0o700, | ||
| 436 | "permissions of source and checked out file are comparable" | ||
| 437 | ); | ||
| 438 | } | ||
| 439 | } | ||
| 440 | Ok(count) | ||
| 441 | } | ||
| 442 | |||
| 443 | pub fn dir_structure<P: AsRef<std::path::Path>>(path: P) -> Vec<std::path::PathBuf> { | ||
| 444 | let path = path.as_ref(); | ||
| 445 | let mut files: Vec<_> = walkdir::WalkDir::new(path) | ||
| 446 | .follow_links(false) | ||
| 447 | .into_iter() | ||
| 448 | .filter_entry(|e| e.path() == path || !e.file_name().to_string_lossy().starts_with('.')) | ||
| 449 | .flatten() | ||
| 450 | .filter_map(|e| (!e.path().symlink_metadata().map_or(true, |m| m.is_dir())).then(|| e.path().to_path_buf())) | ||
| 451 | .collect(); | ||
| 452 | files.sort(); | ||
| 453 | files | ||
| 454 | } | ||
| 455 | |||
| 456 | fn checkout_index_in_tmp_dir( | ||
| 457 | opts: gix_worktree::checkout::Options, | ||
| 458 | name: &str, | ||
| 459 | ) -> crate::Result<(PathBuf, TempDir, gix_index::File, gix_worktree::checkout::Outcome)> { | ||
| 460 | checkout_index_in_tmp_dir_opts(opts, name, |_d| true, |_| Ok(())) | ||
| 461 | } | ||
| 462 | |||
| 463 | fn checkout_index_in_tmp_dir_opts( | ||
| 464 | opts: gix_worktree::checkout::Options, | ||
| 465 | name: &str, | ||
| 466 | mut allow_return_object: impl FnMut(&gix_hash::oid) -> bool + Send + Clone, | ||
| 467 | prep_dest: impl Fn(&Path) -> std::io::Result<()>, | ||
| 468 | ) -> crate::Result<(PathBuf, TempDir, gix_index::File, gix_worktree::checkout::Outcome)> { | ||
| 469 | let source_tree = fixture_path(name); | ||
| 470 | let git_dir = source_tree.join(".git"); | ||
| 471 | let mut index = gix_index::File::at(git_dir.join("index"), gix_hash::Kind::Sha1, Default::default())?; | ||
| 472 | let odb = gix_odb::at(git_dir.join("objects"))?.into_inner().into_arc()?; | ||
| 473 | let destination = tempfile::tempdir_in(std::env::current_dir()?)?; | ||
| 474 | prep_dest(destination.path()).expect("preparation must succeed"); | ||
| 475 | |||
| 476 | let outcome = gix_worktree::checkout( | ||
| 477 | &mut index, | ||
| 478 | destination.path(), | ||
| 479 | move |oid, buf| { | ||
| 480 | if allow_return_object(oid) { | ||
| 481 | odb.find_blob(oid, buf) | ||
| 482 | } else { | ||
| 483 | Err(gix_odb::find::existing_object::Error::NotFound { oid: oid.to_owned() }) | ||
| 484 | } | ||
| 485 | }, | ||
| 486 | &mut progress::Discard, | ||
| 487 | &mut progress::Discard, | ||
| 488 | &AtomicBool::default(), | ||
| 489 | opts, | ||
| 490 | )?; | ||
| 491 | Ok((source_tree, destination, index, outcome)) | ||
| 492 | } | ||
| 493 | |||
| 494 | fn stripped_prefix(prefix: impl AsRef<Path>, source_files: &[PathBuf]) -> Vec<&Path> { | ||
| 495 | source_files.iter().flat_map(|p| p.strip_prefix(&prefix)).collect() | ||
| 496 | } | ||
| 497 | |||
| 498 | fn probe_gitoxide_dir() -> crate::Result<gix_fs::Capabilities> { | ||
| 499 | Ok(gix_fs::Capabilities::probe( | ||
| 500 | std::env::current_dir()?.join("..").join(".git"), | ||
| 501 | )) | ||
| 502 | } | ||
| 503 | |||
| 504 | fn opts_from_probe() -> gix_worktree::checkout::Options { | ||
| 505 | gix_worktree::checkout::Options { | ||
| 506 | fs: probe_gitoxide_dir().unwrap(), | ||
| 507 | destination_is_initially_empty: true, | ||
| 508 | thread_limit: gix_features::parallel::num_threads(None).into(), | ||
| 509 | ..Default::default() | ||
| 510 | } | ||
| 511 | } | ||
| 512 | |||
| 513 | fn paths<'a>(p: impl IntoIterator<Item = &'a str>) -> Vec<PathBuf> { | ||
| 514 | p.into_iter().map(PathBuf::from).collect() | ||
| 515 | } | ||
| 516 | |||
| 517 | fn setup_filter_pipeline(opts: &mut gix_filter::pipeline::Options) { | ||
| 518 | opts.eol_config.auto_crlf = gix_filter::eol::AutoCrlf::Enabled; | ||
| 519 | opts.drivers = vec![gix_filter::Driver { | ||
| 520 | name: "arrow".into(), | ||
| 521 | clean: None, | ||
| 522 | smudge: None, | ||
| 523 | process: Some((driver_exe() + " process").into()), | ||
| 524 | required: true, | ||
| 525 | }]; | ||
| 526 | } | ||
| @@ -1,8 +1,4 @@ | |||
| 1 | mod cache; | 1 | mod cache; |
| 2 | mod checkout; | ||
| 3 | mod status; | ||
| 4 | |||
| 5 | use std::path::{Path, PathBuf}; | ||
| 6 | 2 | ||
| 7 | use gix_hash::ObjectId; | 3 | use gix_hash::ObjectId; |
| 8 | pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>; | 4 | pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>; |
| @@ -10,8 +6,3 @@ pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>; · 1 unmodified line | |||
| 10 | pub fn hex_to_id(hex: &str) -> ObjectId { | 6 | pub fn hex_to_id(hex: &str) -> ObjectId { |
| 11 | ObjectId::from_hex(hex.as_bytes()).expect("40 bytes hex") | 7 | ObjectId::from_hex(hex.as_bytes()).expect("40 bytes hex") |
| 12 | } | 8 | } |
| 13 | |||
| 14 | pub fn fixture_path(name: &str) -> PathBuf { | ||
| 15 | let dir = gix_testtools::scripted_fixture_read_only(Path::new(name).with_extension("sh")).expect("script works"); | ||
| 16 | dir | ||
| 17 | } | ||
| @@ -1,226 +0,0 @@ | |||
| 1 | use std::sync::{ | ||
| 2 | atomic::{AtomicUsize, Ordering}, | ||
| 3 | Arc, | ||
| 4 | }; | ||
| 5 | |||
| 6 | use bstr::BStr; | ||
| 7 | use filetime::{set_file_mtime, FileTime}; | ||
| 8 | use gix_index as index; | ||
| 9 | use gix_index::Entry; | ||
| 10 | use gix_worktree::{ | ||
| 11 | status, | ||
| 12 | status::{ | ||
| 13 | content::{CompareBlobs, FastEq, ReadDataOnce}, | ||
| 14 | Change, Options, Recorder, | ||
| 15 | }, | ||
| 16 | }; | ||
| 17 | |||
| 18 | use crate::fixture_path; | ||
| 19 | |||
| 20 | // since tests are fixtures a bunch of stat information (like inode number) | ||
| 21 | // changes when extracting the data so we need to disable all advanced stat | ||
| 22 | // changes and only look at mtime seconds and file size to properly | ||
| 23 | // test all code paths (and to trigger racy git). | ||
| 24 | const TEST_OPTIONS: index::entry::stat::Options = index::entry::stat::Options { | ||
| 25 | trust_ctime: false, | ||
| 26 | check_stat: false, | ||
| 27 | use_nsec: false, | ||
| 28 | use_stdev: false, | ||
| 29 | }; | ||
| 30 | |||
| 31 | fn fixture(name: &str, expected_status: &[(&BStr, Option<Change>, bool)]) { | ||
| 32 | let worktree = fixture_path(name); | ||
| 33 | let git_dir = worktree.join(".git"); | ||
| 34 | let mut index = gix_index::File::at(git_dir.join("index"), gix_hash::Kind::Sha1, Default::default()).unwrap(); | ||
| 35 | let mut recorder = Recorder::default(); | ||
| 36 | status( | ||
| 37 | &mut index, | ||
| 38 | &worktree, | ||
| 39 | &mut recorder, | ||
| 40 | FastEq, | ||
| 41 | |_, _| Ok::<_, std::convert::Infallible>(gix_object::BlobRef { data: &[] }), | ||
| 42 | Options { | ||
| 43 | fs: gix_fs::Capabilities::probe(git_dir), | ||
| 44 | stat: TEST_OPTIONS, | ||
| 45 | ..Options::default() | ||
| 46 | }, | ||
| 47 | ) | ||
| 48 | .unwrap(); | ||
| 49 | recorder.records.sort_unstable_by_key(|(name, _, _)| *name); | ||
| 50 | assert_eq!(recorder.records, expected_status) | ||
| 51 | } | ||
| 52 | |||
| 53 | #[test] | ||
| 54 | fn removed() { | ||
| 55 | fixture( | ||
| 56 | "status_removed", | ||
| 57 | &[ | ||
| 58 | (BStr::new(b"dir/content"), Some(Change::Removed), false), | ||
| 59 | (BStr::new(b"dir/sub-dir/symlink"), Some(Change::Removed), false), | ||
| 60 | (BStr::new(b"empty"), Some(Change::Removed), false), | ||
| 61 | (BStr::new(b"executable"), Some(Change::Removed), false), | ||
| 62 | ], | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | #[test] | ||
| 67 | fn intent_to_add() { | ||
| 68 | fixture( | ||
| 69 | "status_intent_to_add", | ||
| 70 | &[(BStr::new(b"content"), Some(Change::IntentToAdd), false)], | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 74 | #[test] | ||
| 75 | fn conflict() { | ||
| 76 | fixture( | ||
| 77 | "status_conflict", | ||
| 78 | &[( | ||
| 79 | BStr::new(b"content"), | ||
| 80 | Some(Change::Modification { | ||
| 81 | executable_bit_changed: false, | ||
| 82 | content_change: Some(()), | ||
| 83 | }), | ||
| 84 | true, | ||
| 85 | )], | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | #[test] | ||
| 90 | fn unchanged() { | ||
| 91 | fixture("status_unchanged", &[]); | ||
| 92 | } | ||
| 93 | |||
| 94 | #[test] | ||
| 95 | #[cfg_attr( | ||
| 96 | windows, | ||
| 97 | ignore = "needs work, on windows plenty of additional files are considered modified for some reason" | ||
| 98 | )] | ||
| 99 | fn modified() { | ||
| 100 | fixture( | ||
| 101 | "status_changed", | ||
| 102 | &[ | ||
| 103 | ( | ||
| 104 | BStr::new(b"dir/content"), | ||
| 105 | Some(Change::Modification { | ||
| 106 | executable_bit_changed: true, | ||
| 107 | content_change: None, | ||
| 108 | }), | ||
| 109 | false, | ||
| 110 | ), | ||
| 111 | ( | ||
| 112 | BStr::new(b"dir/content2"), | ||
| 113 | Some(Change::Modification { | ||
| 114 | executable_bit_changed: false, | ||
| 115 | content_change: Some(()), | ||
| 116 | }), | ||
| 117 | false, | ||
| 118 | ), | ||
| 119 | (BStr::new(b"empty"), Some(Change::Type), false), | ||
| 120 | ( | ||
| 121 | BStr::new(b"executable"), | ||
| 122 | Some(Change::Modification { | ||
| 123 | executable_bit_changed: true, | ||
| 124 | content_change: Some(()), | ||
| 125 | }), | ||
| 126 | false, | ||
| 127 | ), | ||
| 128 | ], | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | #[test] | ||
| 133 | fn racy_git() { | ||
| 134 | let timestamp = 940040400; | ||
| 135 | // we need a writable fixture because we have to mess with `mtimes` manually, because touch -d | ||
| 136 | // respects the locale so the test wouldn't work depending on the timezone you | ||
| 137 | // run your test in. | ||
| 138 | let dir = gix_testtools::scripted_fixture_writable("racy_git.sh").expect("script works"); | ||
| 139 | let worktree = dir.path(); | ||
| 140 | let git_dir = worktree.join(".git"); | ||
| 141 | let fs = gix_fs::Capabilities::probe(&git_dir); | ||
| 142 | let mut index = gix_index::File::at(git_dir.join("index"), gix_hash::Kind::Sha1, Default::default()).unwrap(); | ||
| 143 | |||
| 144 | #[derive(Clone)] | ||
| 145 | struct CountCalls(Arc<AtomicUsize>, FastEq); | ||
| 146 | impl CompareBlobs for CountCalls { | ||
| 147 | type Output = (); | ||
| 148 | |||
| 149 | fn compare_blobs<'a, E>( | ||
| 150 | &mut self, | ||
| 151 | entry: &'a Entry, | ||
| 152 | worktree_blob_size: usize, | ||
| 153 | worktree_blob: impl ReadDataOnce<'a, E>, | ||
| 154 | entry_blob: impl ReadDataOnce<'a, E>, | ||
| 155 | ) -> Result<Option<Self::Output>, E> { | ||
| 156 | self.0.fetch_add(1, Ordering::Relaxed); | ||
| 157 | self.1 | ||
| 158 | .compare_blobs(entry, worktree_blob_size, worktree_blob, entry_blob) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | // We artificially mess with the entry's `mtime` so that it's before the timestamp saved by git. | ||
| 163 | // This would usually mean an invalid fs/invalid index file and as a result the racy git | ||
| 164 | // mitigation doesn't work and the worktree shows up as unchanged even tough the file did | ||
| 165 | // change. | ||
| 166 | // This case doesn't happen in the realworld (except for file corruption) but | ||
| 167 | // makes sure we are actually hitting the right codepath. | ||
| 168 | index.entries_mut()[0].stat.mtime.secs = timestamp; | ||
| 169 | set_file_mtime(worktree.join("content"), FileTime::from_unix_time(timestamp as i64, 0)) | ||
| 170 | .expect("changing filetime works"); | ||
| 171 | let mut recorder = Recorder::default(); | ||
| 172 | |||
| 173 | let count = Arc::new(AtomicUsize::new(0)); | ||
| 174 | let counter = CountCalls(count.clone(), FastEq); | ||
| 175 | status( | ||
| 176 | &mut index, | ||
| 177 | worktree, | ||
| 178 | &mut recorder, | ||
| 179 | counter.clone(), | ||
| 180 | |_, _| Err(std::io::Error::new(std::io::ErrorKind::Other, "no odb access expected")), | ||
| 181 | Options { | ||
| 182 | fs, | ||
| 183 | stat: TEST_OPTIONS, | ||
| 184 | ..Options::default() | ||
| 185 | }, | ||
| 186 | ) | ||
| 187 | .unwrap(); | ||
| 188 | assert_eq!(count.load(Ordering::Relaxed), 0, "no blob content is accessed"); | ||
| 189 | assert_eq!(recorder.records, &[], "the testcase triggers racy git"); | ||
| 190 | |||
| 191 | // Now we also backdate the index timestamp to match the artificially created | ||
| 192 | // mtime above this is now a realistic realworld race-condition which should trigger racy git | ||
| 193 | // and cause proper output. | ||
| 194 | index.set_timestamp(FileTime::from_unix_time(timestamp as i64, 0)); | ||
| 195 | let mut recorder = Recorder::default(); | ||
| 196 | status( | ||
| 197 | &mut index, | ||
| 198 | worktree, | ||
| 199 | &mut recorder, | ||
| 200 | counter, | ||
| 201 | |_, _| Err(std::io::Error::new(std::io::ErrorKind::Other, "no odb access expected")), | ||
| 202 | Options { | ||
| 203 | fs, | ||
| 204 | stat: TEST_OPTIONS, | ||
| 205 | ..Options::default() | ||
| 206 | }, | ||
| 207 | ) | ||
| 208 | .unwrap(); | ||
| 209 | assert_eq!( | ||
| 210 | count.load(Ordering::Relaxed), | ||
| 211 | 1, | ||
| 212 | "no we needed to access the blob content" | ||
| 213 | ); | ||
| 214 | assert_eq!( | ||
| 215 | recorder.records, | ||
| 216 | &[( | ||
| 217 | BStr::new(b"content"), | ||
| 218 | Some(Change::Modification { | ||
| 219 | executable_bit_changed: false, | ||
| 220 | content_change: Some(()), | ||
| 221 | }), | ||
| 222 | false | ||
| 223 | )], | ||
| 224 | "racy change is correctly detected" | ||
| 225 | ); | ||
| 226 | } | ||
| @@ -147,10 +147,6 @@ unit-tests: · 146 unmodified lines | |||
| 147 | set -ex; \ | 147 | set -ex; \ |
| 148 | cargo test; \ | 148 | cargo test; \ |
| 149 | cargo test --features verbose-object-parsing-errors | 149 | cargo test --features verbose-object-parsing-errors |
| 150 | cd gix-worktree; \ | ||
| 151 | set -ex; \ | ||
| 152 | cargo test; \ | ||
| 153 | cargo test --features "internal-testing-gix-features-parallel" | ||
| 154 | cargo test -p gix-tempfile --features signals | 150 | cargo test -p gix-tempfile --features signals |
| 155 | cargo test -p gix-tempfile | 151 | cargo test -p gix-tempfile |
| 156 | cargo test -p gix-features | 152 | cargo test -p gix-features |