Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 32 additions & 10 deletions gix-index/src/entry/mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,44 @@ impl Mode {
stat: &crate::fs::Metadata,
has_symlinks: bool,
executable_bit: bool,
) -> Option<Change> {
self.change_to_match_fs_with_values(
stat.is_file(),
stat.is_dir(),
stat.is_symlink(),
stat.is_executable(),
has_symlinks,
executable_bit,
)
}

/// Like [`change_to_match_fs`](Self::change_to_match_fs) but accepts pre-extracted
/// file-type and permission bits, for callers that already have them (e.g. cached
/// metadata from a batched directory enumeration).
pub fn change_to_match_fs_with_values(
self,
is_file: bool,
is_dir: bool,
is_symlink: bool,
is_executable: bool,
has_symlinks: bool,
executable_bit: bool,
) -> Option<Change> {
match self {
Mode::FILE if !stat.is_file() => (),
Mode::SYMLINK if stat.is_symlink() => return None,
Mode::SYMLINK if has_symlinks && !stat.is_symlink() => (),
Mode::SYMLINK if !has_symlinks && !stat.is_file() => (),
Mode::COMMIT | Mode::DIR if !stat.is_dir() => (),
Mode::FILE if executable_bit && stat.is_executable() => return Some(Change::ExecutableBit),
Mode::FILE_EXECUTABLE if executable_bit && !stat.is_executable() => return Some(Change::ExecutableBit),
Mode::FILE if !is_file => (),
Mode::SYMLINK if is_symlink => return None,
Mode::SYMLINK if has_symlinks && !is_symlink => (),
Mode::SYMLINK if !has_symlinks && !is_file => (),
Mode::COMMIT | Mode::DIR if !is_dir => (),
Mode::FILE if executable_bit && is_executable => return Some(Change::ExecutableBit),
Mode::FILE_EXECUTABLE if executable_bit && !is_executable => return Some(Change::ExecutableBit),
_ => return None,
}
let new_mode = if stat.is_dir() {
let new_mode = if is_dir {
Mode::COMMIT
} else if executable_bit && stat.is_executable() {
} else if executable_bit && is_executable {
Mode::FILE_EXECUTABLE
} else if has_symlinks && stat.is_symlink() {
} else if has_symlinks && is_symlink {
Mode::SYMLINK
} else {
Mode::FILE
Expand Down
9 changes: 9 additions & 0 deletions gix-status/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,21 @@ gix-diff = { version = "^0.62.0", path = "../gix-diff", default-features = false
thiserror = "2.0.18"
filetime = "0.2.27"
bstr = { version = "1.12.0", default-features = false }
hashbrown = "0.16.0"

document-features = { version = "0.2.0", optional = true }

[target.'cfg(not(target_has_atomic = "64"))'.dependencies]
portable-atomic = "1"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.1", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
# For SECURITY_ATTRIBUTES in CreateFileW (used by metadata_cache::prepare on Windows).
"Win32_Security",
] }

[dev-dependencies]
gix-hash = { path = "../gix-hash", features = ["sha1"] }

Expand Down
174 changes: 135 additions & 39 deletions gix-status/src/index_as_worktree/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,67 @@ use crate::{
},
is_dir_to_mode, AtomicU64, SymlinkCheck,
};
#[cfg(windows)]
use crate::metadata_cache::{self, CachedMetadata, MetadataCache};

/// Windows-only union of live `lstat` metadata and pre-cached metadata, so
/// `compute_status` sees one shape. Other platforms use `gix_index::fs::Metadata`
/// directly.
#[cfg(windows)]
enum FileMetadata<'a> {
Live(gix_index::fs::Metadata),
Cached(&'a CachedMetadata),
}

#[cfg(windows)]
impl FileMetadata<'_> {
fn is_dir(&self) -> bool {
match self {
Self::Live(m) => m.is_dir(),
Self::Cached(c) => c.is_dir,
}
}

fn is_symlink(&self) -> bool {
match self {
Self::Live(m) => m.is_symlink(),
Self::Cached(c) => c.is_symlink,
}
}

fn len(&self) -> u64 {
match self {
Self::Live(m) => m.len(),
Self::Cached(c) => c.size,
}
}

fn to_stat(&self) -> Result<gix_index::entry::Stat, std::time::SystemTimeError> {
match self {
Self::Live(m) => gix_index::entry::Stat::from_fs(m),
Self::Cached(c) => Ok(c.to_stat()),
}
}

fn mode_change(
&self,
entry_mode: gix_index::entry::Mode,
has_symlinks: bool,
executable_bit: bool,
) -> Option<gix_index::entry::mode::Change> {
match self {
Self::Live(m) => entry_mode.change_to_match_fs(m, has_symlinks, executable_bit),
Self::Cached(c) => entry_mode.change_to_match_fs_with_values(
!c.is_dir && !c.is_symlink, // is_file: regular file (not dir, not symlink)
c.is_dir,
c.is_symlink,
c.is_executable,
has_symlinks,
executable_bit,
),
}
}
}

/// Calculates the changes that need to be applied to an `index` to match the state of the `worktree` and makes them
/// observable in `collector`, along with information produced by `compare` which gets to see blobs that may have changes, and
Expand Down Expand Up @@ -63,6 +124,8 @@ pub fn index_as_worktree<'index, T, U, Find, E>(
stack,
filter,
should_interrupt,
#[cfg(windows)]
metadata_cache,
}: Context<'_>,
options: Options,
) -> Result<Outcome, Error>
Expand Down Expand Up @@ -122,6 +185,8 @@ where
path_backing,
filter,
options,
#[cfg(windows)]
metadata_cache,

skipped_by_pathspec,
skipped_by_entry_flags,
Expand Down Expand Up @@ -228,6 +293,10 @@ struct State<'a, 'b> {
filter: gix_filter::Pipeline,
path_backing: &'b gix_index::PathStorageRef,
options: &'a Options,
/// Optional pre-populated metadata cache for faster status checks on Windows.
/// Cache lookups happen before falling back to per-file syscalls.
#[cfg(windows)]
metadata_cache: Option<&'a MetadataCache>,

skipped_by_pathspec: &'a AtomicUsize,
skipped_by_entry_flags: &'a AtomicUsize,
Expand Down Expand Up @@ -374,53 +443,80 @@ impl<'index> State<'_, 'index> {
}
Err(err) => return Err(Error::Io(err.into())),
};
self.symlink_metadata_calls.fetch_add(1, Ordering::Relaxed);
let metadata = match gix_index::fs::Metadata::from_path_no_follow(worktree_path) {
Ok(metadata) if metadata.is_dir() => {
// index entries are normally only for files/symlinks
// if a file turned into a directory it was removed
// the only exception here are submodules which are
// part of the index despite being directories
if entry.mode.is_submodule() {
let status = submodule
.status(entry, rela_path)
.map_err(|err| Error::SubmoduleStatus {
rela_path: rela_path.into(),
source: Box::new(err),
})?;
return Ok(status.map(|status| Change::SubmoduleModification(status).into()));
} else {
return Ok(Some(Change::Removed.into()));

// Acquire metadata. On Windows we consult the metadata cache first and
// only fall back to a syscall on miss; on other platforms per-file
// `lstat` is already fast, so we just do the syscall directly.
#[cfg(windows)]
let metadata = if let Some(cached) = self.metadata_cache.and_then(|c| metadata_cache::lookup(c, rela_path)) {
FileMetadata::Cached(cached)
} else {
self.symlink_metadata_calls.fetch_add(1, Ordering::Relaxed);
match gix_index::fs::Metadata::from_path_no_follow(worktree_path) {
Ok(m) => FileMetadata::Live(m),
Err(err) if gix_fs::io_err::is_not_found(err.kind(), err.raw_os_error()) => {
return Ok(Some(Change::Removed.into()))
}
Err(err) => return Err(Error::Io(err.into())),
}
Ok(metadata) => metadata,
Err(err) if gix_fs::io_err::is_not_found(err.kind(), err.raw_os_error()) => {
return Ok(Some(Change::Removed.into()))
}
Err(err) => {
return Err(Error::Io(err.into()));
};
#[cfg(not(windows))]
let metadata = {
self.symlink_metadata_calls.fetch_add(1, Ordering::Relaxed);
match gix_index::fs::Metadata::from_path_no_follow(worktree_path) {
Ok(m) => m,
Err(err) if gix_fs::io_err::is_not_found(err.kind(), err.raw_os_error()) => {
return Ok(Some(Change::Removed.into()))
}
Err(err) => return Err(Error::Io(err.into())),
}
};

// Handle directory: index entries are normally only for files/symlinks.
// If a file turned into a directory it was removed.
// The only exception here are submodules which are part of the index despite being directories.
if metadata.is_dir() {
if entry.mode.is_submodule() {
let status = submodule
.status(entry, rela_path)
.map_err(|err| Error::SubmoduleStatus {
rela_path: rela_path.into(),
source: Box::new(err),
})?;
return Ok(status.map(|status| Change::SubmoduleModification(status).into()));
} else {
return Ok(Some(Change::Removed.into()));
}
}

if entry.flags.contains(gix_index::entry::Flags::INTENT_TO_ADD) {
return Ok(Some(EntryStatus::IntentToAdd));
}

#[cfg(windows)]
let new_stat = metadata.to_stat()?;
#[cfg(not(windows))]
let new_stat = gix_index::entry::Stat::from_fs(&metadata)?;
let executable_bit_changed =
match entry
.mode
.change_to_match_fs(&metadata, self.options.fs.symlink, self.options.fs.executable_bit)
{
Some(gix_index::entry::mode::Change::Type { new_mode }) => {
return Ok(Some(
Change::Type {
worktree_mode: new_mode,
}
.into(),
))
}
Some(gix_index::entry::mode::Change::ExecutableBit) => true,
None => false,
};

#[cfg(windows)]
let mode_change =
metadata.mode_change(entry.mode, self.options.fs.symlink, self.options.fs.executable_bit);
#[cfg(not(windows))]
let mode_change = entry
.mode
.change_to_match_fs(&metadata, self.options.fs.symlink, self.options.fs.executable_bit);
let executable_bit_changed = match mode_change {
Some(gix_index::entry::mode::Change::Type { new_mode }) => {
return Ok(Some(
Change::Type {
worktree_mode: new_mode,
}
.into(),
))
}
Some(gix_index::entry::mode::Change::ExecutableBit) => true,
None => false,
};

// We implement racy-git. See racy-git.txt in the git documentation for detailed documentation.
//
Expand Down
7 changes: 7 additions & 0 deletions gix-status/src/index_as_worktree/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ use std::sync::atomic::AtomicBool;
use bstr::{BStr, BString};
use gix_index::entry;

#[cfg(windows)]
use crate::metadata_cache::MetadataCache;

/// The error returned by [index_as_worktree()`](crate::index_as_worktree()).
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
Expand Down Expand Up @@ -55,6 +58,10 @@ pub struct Context<'a> {
pub filter: gix_filter::Pipeline,
/// A flag to query to learn if cancellation is requested.
pub should_interrupt: &'a AtomicBool,
/// Windows-only pre-populated metadata cache. See [`crate::metadata_cache`].
/// Transparent: `None`/empty/partial are all correct.
#[cfg(windows)]
pub metadata_cache: Option<&'a MetadataCache>,
}

/// Provide additional information collected during the runtime of [`index_as_worktree()`](crate::index_as_worktree()).
Expand Down
2 changes: 2 additions & 0 deletions gix-status/src/index_as_worktree_with_renames/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ pub(super) mod function {
stack,
filter,
should_interrupt: ctx.should_interrupt,
#[cfg(windows)]
metadata_cache: ctx.metadata_cache,
},
options.tracked_file_modifications,
)
Expand Down
5 changes: 5 additions & 0 deletions gix-status/src/index_as_worktree_with_renames/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ pub struct Context<'a> {
pub should_interrupt: &'a AtomicBool,
/// The context for the directory walk.
pub dirwalk: DirwalkContext<'a>,
/// An optional pre-populated metadata cache for faster status checks on Windows.
///
/// See [`crate::index_as_worktree::Context::metadata_cache`] for details.
#[cfg(windows)]
pub metadata_cache: Option<&'a crate::metadata_cache::MetadataCache>,
}

/// All information that is required to perform a [dirwalk](gix_dir::walk()).
Expand Down
13 changes: 13 additions & 0 deletions gix-status/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ use portable_atomic::AtomicU64;
pub mod index_as_worktree;
pub use index_as_worktree::function::index_as_worktree;

/// The metadata cache is a **Windows-only** optimization. Its job is to skip
/// per-file `lstat` calls by pre-populating stat results via one batched
/// directory enumeration. That trade only pays off where per-file stat is
/// expensive (Windows), not on Linux/macOS where `lstat` is sub-microsecond.
/// A Linux-friendly cache would almost certainly be keyed by *directory* (à la
/// git's `UNTRACKED_CACHE`) rather than by file path, so forcing this type to
/// exist there would encourage the wrong abstraction. Keep the two separate;
/// lift this gate if a cross-platform use case actually appears.
#[cfg(windows)]
pub mod metadata_cache;
#[cfg(windows)]
pub use metadata_cache::{CachedMetadata, MetadataCache};

#[cfg(feature = "worktree-rewrites")]
pub mod index_as_worktree_with_renames;
#[cfg(feature = "worktree-rewrites")]
Expand Down
Loading
Loading