Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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::{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| c.get(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