#![allow(
clippy::collapsible_if,
clippy::if_same_then_else,
clippy::ptr_arg,
clippy::too_many_arguments
)]
use sley_config::GitConfig;
use sley_core::{
BString, GitError, MissingObjectContext, MissingObjectKind, ObjectFormat, ObjectId, RepoPath,
Result,
};
use sley_index::{
BorrowedIndex, CacheTree, Index, IndexEntry, IndexEntryRef, SPARSE_DIR_MODE, SplitIndexLink,
Stage, UntrackedCache, UntrackedCacheDir, UntrackedCacheOidStat, UntrackedCacheStatData,
};
use sley_object::{Commit, EncodedObject, ObjectType, Tree, TreeEntry, tree_entry_object_type};
use sley_odb::{FileObjectDatabase, ObjectPresenceChecker, ObjectReader, ObjectWriter};
use sley_refs::{FileRefStore, RefTarget, RefUpdate, ReflogEntry, branch_ref_name};
use std::borrow::Cow;
use std::cell::{Cell, RefCell};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::{Read, Write};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::{env, fs};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorktreeStatus {
Clean,
Modified(RepoPath),
Added(RepoPath),
Deleted(RepoPath),
Untracked(RepoPath),
}
pub trait WorktreeScanner {
fn status(&self) -> Result<Vec<WorktreeStatus>>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SparseCheckout {
pub patterns: Vec<Vec<u8>>,
pub sparse_index: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SparseCheckoutMode {
#[default]
Auto,
Full,
Cone,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplySparseResult {
pub materialized: Vec<Vec<u8>>,
pub skipped: Vec<Vec<u8>>,
pub not_up_to_date: Vec<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateIndexResult {
pub entries: usize,
pub updated: Vec<ObjectId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AddUpdateTrackedAction {
Add(Vec<u8>),
Remove(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AddExactTrackedPathResult {
Handled(Option<AddUpdateTrackedAction>),
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheInfoEntry {
pub mode: u32,
pub oid: ObjectId,
pub path: Vec<u8>,
pub stage: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexInfoRecord {
Add(CacheInfoEntry),
Remove { path: Vec<u8> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UpdateIndexOptions {
pub add: bool,
pub remove: bool,
pub force_remove: bool,
pub chmod: Option<bool>,
pub info_only: bool,
pub ignore_skip_worktree_entries: bool,
pub allow_skip_worktree_entries: bool,
}
impl UpdateIndexOptions {
fn path_mode(&self) -> UpdateIndexPathMode {
UpdateIndexPathMode {
add: self.add,
remove: self.remove,
force_remove: self.force_remove,
info_only: self.info_only,
chmod: self.chmod,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LargeObjectPolicy {
threshold: u64,
compression_level: u32,
pack_size_limit: Option<u64>,
}
impl LargeObjectPolicy {
fn from_config(git_dir: &Path, parameters_env: Option<&str>) -> Result<Self> {
let config = effective_worktree_config(git_dir, parameters_env)?;
let threshold = match config.get("core", None, "bigfilethreshold") {
Some(value) => match sley_config::parse_config_int(value) {
Some(value) if value >= 0 => value as u64,
_ => {
eprintln!(
"fatal: bad numeric config value '{value}' for 'core.bigfilethreshold': invalid unit"
);
return Err(GitError::Exit(128));
}
},
None => 512 * 1024 * 1024,
};
let compression_level = pack_compression_level(&config);
let pack_size_limit = config
.get("pack", None, "packSizeLimit")
.and_then(sley_config::parse_config_int)
.and_then(|value| (value > 0).then_some(value as u64));
Ok(Self {
threshold,
compression_level,
pack_size_limit,
})
}
}
fn effective_worktree_config(git_dir: &Path, parameters_env: Option<&str>) -> Result<GitConfig> {
let common = common_git_dir_for_worktree_config(git_dir);
let context = sley_config::ConfigIncludeContext::new(
Some(common.clone()),
sley_config::repo_current_branch_name(git_dir),
);
let mut config = sley_config::load_effective_config(&common, &context)?;
if let Ok(parameters) = sley_config::injected_config_parameters(parameters_env) {
let base = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
sley_config::append_injected_config_sections_with_includes(
&mut config,
¶meters,
&context,
&base,
)?;
}
Ok(config)
}
fn common_git_dir_for_worktree_config(git_dir: &Path) -> PathBuf {
if let Ok(value) = fs::read_to_string(git_dir.join("commondir")) {
let path = PathBuf::from(value.trim());
if path.is_absolute() {
return path;
}
return git_dir.join(path);
}
git_dir.to_path_buf()
}
fn pack_compression_level(config: &GitConfig) -> u32 {
config_int_in_range(config.get("pack", None, "compression"))
.or_else(|| config_int_in_range(config.get("core", None, "compression")))
.unwrap_or(6)
}
fn config_int_in_range(value: Option<&str>) -> Option<u32> {
let parsed = sley_config::parse_config_int(value?)?;
(0..=9).contains(&parsed).then_some(parsed as u32)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct UpdateIndexPathMode {
pub add: bool,
pub remove: bool,
pub force_remove: bool,
pub info_only: bool,
pub chmod: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct UpdateIndexPath {
pub path: PathBuf,
pub mode: UpdateIndexPathMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WriteTreeOptions {
pub missing_ok: bool,
pub prefix: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShortStatusEntry {
pub index: u8,
pub worktree: u8,
pub path: Vec<u8>,
pub head_mode: Option<u32>,
pub index_mode: Option<u32>,
pub worktree_mode: Option<u32>,
pub head_oid: Option<ObjectId>,
pub index_oid: Option<ObjectId>,
pub submodule: Option<SubmoduleStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShortStatusRow<'a> {
pub index: u8,
pub worktree: u8,
pub path: &'a [u8],
pub head_mode: Option<u32>,
pub index_mode: Option<u32>,
pub worktree_mode: Option<u32>,
pub head_oid: Option<ObjectId>,
pub index_oid: Option<ObjectId>,
pub submodule: Option<SubmoduleStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StreamControl {
#[default]
Continue,
Stop,
}
impl StreamControl {
fn is_stop(self) -> bool {
matches!(self, Self::Stop)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SubmoduleStatus {
pub new_commits: bool,
pub modified_content: bool,
pub untracked_content: bool,
}
impl SubmoduleStatus {
pub fn any(&self) -> bool {
self.new_commits || self.modified_content || self.untracked_content
}
}
pub const DIRTY_SUBMODULE_MODIFIED: u8 = 1;
pub const DIRTY_SUBMODULE_UNTRACKED: u8 = 2;
pub fn submodule_dirt(sub_root: &Path) -> u8 {
let Some(git_dir) = sley_diff_merge::gitlink_git_dir(sub_root) else {
return 0;
};
let Ok(config) = sley_config::read_repo_config(&git_dir, None) else {
return 0;
};
let Ok(format) = config.repository_object_format() else {
return 0;
};
let mut dirt = 0;
let status_result = stream_short_status_with_options(
sub_root,
&git_dir,
format,
ShortStatusOptions {
include_ignored: false,
ignored_mode: StatusIgnoredMode::Traditional,
untracked_mode: StatusUntrackedMode::Normal,
},
|entry| {
if let Some(submodule) = entry.submodule {
if submodule.new_commits || submodule.modified_content {
dirt |= DIRTY_SUBMODULE_MODIFIED;
}
if submodule.untracked_content {
dirt |= DIRTY_SUBMODULE_UNTRACKED;
}
} else if entry.index == b'?' && entry.worktree == b'?' {
dirt |= DIRTY_SUBMODULE_UNTRACKED;
} else {
dirt |= DIRTY_SUBMODULE_MODIFIED;
}
let complete = DIRTY_SUBMODULE_MODIFIED | DIRTY_SUBMODULE_UNTRACKED;
Ok(if dirt == complete {
StreamControl::Stop
} else {
StreamControl::Continue
})
},
);
if status_result.is_err() {
return 0;
}
dirt
}
fn embedded_repo_object_format(sub_root: &Path) -> Option<ObjectFormat> {
let git_dir = sley_diff_merge::gitlink_git_dir(sub_root)?;
sley_config::read_repo_config(&git_dir, None)
.ok()?
.repository_object_format()
.ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatusUntrackedMode {
#[default]
All,
Normal,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatusIgnoredMode {
#[default]
Traditional,
Matching,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ShortStatusOptions {
pub include_ignored: bool,
pub ignored_mode: StatusIgnoredMode,
pub untracked_mode: StatusUntrackedMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorktreeEntryState {
Clean,
Modified,
Deleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AtomicMetadataWriteOptions {
pub fsync_file: bool,
pub fsync_dir: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtomicMetadataWriteResult {
pub path: PathBuf,
pub len: u64,
pub mtime: Option<(u64, u64)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexStatProbe {
entry: IndexEntry,
index_mtime: Option<(u64, u64)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct IndexStatProbeCache {
entries: HashMap<Vec<u8>, IndexEntry>,
index_mtime: Option<(u64, u64)>,
}
impl IndexStatProbe {
pub fn from_index_entry(entry: IndexEntry, index_mtime: Option<(u64, u64)>) -> Self {
Self { entry, index_mtime }
}
pub fn from_index_entry_and_index_path(
entry: IndexEntry,
index_path: impl AsRef<Path>,
) -> Self {
let index_mtime = fs::metadata(index_path.as_ref())
.ok()
.and_then(|metadata| file_mtime_parts(&metadata));
Self { entry, index_mtime }
}
pub fn from_repository_index(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
git_path: &[u8],
) -> Result<Option<Self>> {
let index_path = repository_index_path(git_dir);
cached_repository_index_stat_probe(&index_path, format, git_path)
}
pub fn entry(&self) -> &IndexEntry {
&self.entry
}
pub fn index_mtime(&self) -> Option<(u64, u64)> {
self.index_mtime
}
fn stat_cache_for(
&self,
git_path: &[u8],
expected_oid: &ObjectId,
expected_mode: u32,
) -> Option<IndexStatCache> {
if index_entry_stage(&self.entry) != 0
|| self.entry.path.as_bytes() != git_path
|| self.entry.oid != *expected_oid
|| self.entry.mode != expected_mode
{
return None;
}
let mut entries = HashMap::new();
entries.insert(git_path.to_vec(), self.entry.clone());
Some(IndexStatCache {
entries,
index_mtime: self.index_mtime,
})
}
}
impl IndexStatProbeCache {
pub fn from_index(index: &Index, index_mtime: Option<(u64, u64)>) -> Self {
Self {
entries: stage0_index_entries(index),
index_mtime,
}
}
pub fn from_repository_index(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<Self> {
let index_path = repository_index_path(git_dir);
read_index_stat_probe_cache(&index_path, format)
}
pub fn probe_for_git_path(&self, git_path: &[u8]) -> Option<IndexStatProbe> {
self.entries
.get(git_path)
.cloned()
.map(|entry| IndexStatProbe {
entry,
index_mtime: self.index_mtime,
})
}
pub fn contains_git_path(&self, git_path: &[u8]) -> bool {
self.entries.contains_key(git_path)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn index_mtime(&self) -> Option<(u64, u64)> {
self.index_mtime
}
}
#[derive(Clone)]
struct CachedRepositoryIndexStatProbes {
index_path: PathBuf,
format: ObjectFormat,
len: u64,
mtime: Option<(u64, u64)>,
probes: IndexStatProbeCache,
}
static REPOSITORY_INDEX_STAT_PROBES: OnceLock<Mutex<Option<CachedRepositoryIndexStatProbes>>> =
OnceLock::new();
fn cached_repository_index_stat_probe(
index_path: &Path,
format: ObjectFormat,
git_path: &[u8],
) -> Result<Option<IndexStatProbe>> {
let metadata = match fs::metadata(index_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if let Some(cache) = REPOSITORY_INDEX_STAT_PROBES.get()
&& let Ok(mut guard) = cache.lock()
{
*guard = None;
}
return Ok(None);
}
Err(err) => return Err(err.into()),
};
let len = metadata.len();
let mtime = file_mtime_parts(&metadata);
let cache = REPOSITORY_INDEX_STAT_PROBES.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cache.lock()
&& let Some(cached) = guard.as_ref()
&& cached.index_path == index_path
&& cached.format == format
&& cached.len == len
&& cached.mtime == mtime
{
return Ok(cached.probes.probe_for_git_path(git_path));
}
let probes = read_index_stat_probe_cache_with_metadata(index_path, format, mtime)?;
let probe = probes.probe_for_git_path(git_path);
if let Ok(mut guard) = cache.lock() {
*guard = Some(CachedRepositoryIndexStatProbes {
index_path: index_path.to_path_buf(),
format,
len,
mtime,
probes: probes.clone(),
});
}
Ok(probe)
}
fn read_index_stat_probe_cache(
index_path: &Path,
format: ObjectFormat,
) -> Result<IndexStatProbeCache> {
let metadata = match fs::metadata(index_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(IndexStatProbeCache::default());
}
Err(err) => return Err(err.into()),
};
read_index_stat_probe_cache_with_metadata(index_path, format, file_mtime_parts(&metadata))
}
fn read_index_stat_probe_cache_with_metadata(
index_path: &Path,
format: ObjectFormat,
index_mtime: Option<(u64, u64)>,
) -> Result<IndexStatProbeCache> {
let bytes = fs::read(index_path)?;
let index = Index::parse(&bytes, format)?;
Ok(IndexStatProbeCache::from_index(&index, index_mtime))
}
fn stage0_index_entries(index: &Index) -> HashMap<Vec<u8>, IndexEntry> {
let mut entries = HashMap::new();
for entry in &index.entries {
if index_entry_stage(entry) == 0 {
entries.insert(entry.path.as_bytes().to_vec(), entry.clone());
}
}
entries
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutResult {
pub branch: String,
pub oid: ObjectId,
pub files: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoreResult {
pub restored: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckoutStage {
Ours,
Theirs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckoutConflictStyle {
Merge,
Diff3,
}
#[derive(Debug, Clone, Copy)]
pub struct CheckoutIndexPathOptions<'a> {
pub force: bool,
pub merge: bool,
pub stage: Option<CheckoutStage>,
pub conflict_style: CheckoutConflictStyle,
pub smudge_config: Option<&'a GitConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoveResult {
pub removed: Vec<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MoveResult {
pub source: Vec<u8>,
pub destination: Vec<u8>,
pub skipped: bool,
pub fatal: Option<String>,
pub details: Vec<MoveDetail>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MoveDetail {
pub source: Vec<u8>,
pub destination: Vec<u8>,
pub skipped: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GitmodulesMove {
source: Vec<u8>,
destination: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GitlinkGitdirMove {
git_dir: PathBuf,
destination_root: PathBuf,
}
pub fn repository_index_path(git_dir: impl AsRef<Path>) -> PathBuf {
env::var_os("GIT_INDEX_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| git_dir.as_ref().join("index"))
}
pub fn read_repository_index(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<Option<Index>> {
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(None);
}
Ok(Some(sley_index::read_repository_index(git_dir, format)?))
}
fn empty_index() -> Index {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
}
pub fn worktree_root_for_git_dir(git_dir: &Path) -> Result<Option<PathBuf>> {
if git_dir.join("commondir").is_file() {
let gitdir_file = git_dir.join("gitdir");
if gitdir_file.is_file() {
let value = fs::read_to_string(&gitdir_file)?;
let worktree_git_file = resolve_worktree_admin_path(git_dir, value.trim());
if let Some(worktree) = worktree_git_file.parent() {
return fs::canonicalize(worktree)
.map(Some)
.map_err(|err| GitError::Io(err.to_string()));
}
}
}
if let Ok(config) = sley_config::read_repo_config(git_dir, None) {
if config.get_bool("core", None, "bare") == Some(true) {
return Ok(None);
}
if let Some(worktree) = config.get("core", None, "worktree") {
let worktree = PathBuf::from(worktree);
let worktree = if worktree.is_absolute() {
worktree
} else {
git_dir.join(worktree)
};
return fs::canonicalize(worktree)
.map(Some)
.map_err(|err| GitError::Io(err.to_string()));
}
}
if git_dir.file_name().and_then(|name| name.to_str()) != Some(".git") {
return Ok(None);
}
git_dir
.parent()
.map(Path::to_path_buf)
.map(Some)
.ok_or_else(|| GitError::InvalidPath("git dir has no parent worktree".into()))
}
pub fn common_git_dir_for_git_dir(git_dir: &Path) -> Result<PathBuf> {
if let Some(common_dir) = env::var_os("GIT_COMMON_DIR") {
return Ok(PathBuf::from(common_dir));
}
let commondir = git_dir.join("commondir");
if commondir.is_file() {
let value = fs::read_to_string(&commondir)?;
let path = PathBuf::from(value.trim());
let common = if path.is_absolute() {
path
} else {
git_dir.join(path)
};
return fs::canonicalize(common).map_err(|err| GitError::Io(err.to_string()));
}
fs::canonicalize(git_dir).map_err(|err| GitError::Io(err.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedSymrefWorktree {
pub refname: String,
pub path: PathBuf,
}
struct WorktreeAdmin {
git_dir: PathBuf,
path: Option<PathBuf>,
}
pub fn find_shared_symref(
git_dir: &Path,
symref: &str,
target: &str,
) -> Result<Option<SharedSymrefWorktree>> {
let common_git_dir = common_git_dir_for_git_dir(git_dir)?;
for admin in worktree_admins(&common_git_dir)? {
if worktree_uses_symref(&admin.git_dir, symref, target)? {
let path = admin
.path
.unwrap_or_else(|| admin.git_dir.clone())
.to_string_lossy()
.into_owned();
return Ok(Some(SharedSymrefWorktree {
refname: target.to_string(),
path: PathBuf::from(path),
}));
}
}
Ok(None)
}
pub fn worktree_refs_in_use(git_dir: &Path) -> Result<HashSet<String>> {
let common_git_dir = common_git_dir_for_git_dir(git_dir)?;
let mut refs = HashSet::new();
for admin in worktree_admins(&common_git_dir)? {
if let Ok(head) = fs::read_to_string(admin.git_dir.join("HEAD")) {
let head = head.trim();
if let Some(target) = head.strip_prefix("ref: ") {
refs.insert(target.to_string());
}
refs.extend(worktree_detached_operation_refs(&admin.git_dir));
}
}
Ok(refs)
}
fn worktree_admins(common_git_dir: &Path) -> Result<Vec<WorktreeAdmin>> {
let mut admins = Vec::new();
admins.push(WorktreeAdmin {
git_dir: common_git_dir.to_path_buf(),
path: worktree_root_for_git_dir(common_git_dir)?,
});
let worktrees_dir = common_git_dir.join("worktrees");
let Ok(entries) = fs::read_dir(worktrees_dir) else {
return Ok(admins);
};
for entry in entries {
let entry = entry?;
let git_dir = entry.path();
let path = linked_worktree_path(&git_dir);
admins.push(WorktreeAdmin { git_dir, path });
}
Ok(admins)
}
fn linked_worktree_path(admin_dir: &Path) -> Option<PathBuf> {
let gitdir = fs::read_to_string(admin_dir.join("gitdir")).ok()?;
let gitdir = gitdir.trim();
if gitdir.is_empty() {
return None;
}
let gitdir_path = resolve_worktree_admin_path(admin_dir, gitdir);
gitdir_path.parent().map(|path| {
fs::canonicalize(path).unwrap_or_else(|_| normalize_lexical_worktree_path(path))
})
}
fn normalize_lexical_worktree_path(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
_ => out.push(component.as_os_str()),
}
}
out
}
fn worktree_uses_symref(git_dir: &Path, symref: &str, target: &str) -> Result<bool> {
if symref != "HEAD" {
return Ok(false);
}
let Ok(head) = fs::read_to_string(git_dir.join(symref)) else {
return Ok(false);
};
let head = head.trim();
if head.strip_prefix("ref: ") == Some(target) {
return Ok(true);
}
if worktree_rebase_update_refs(git_dir)
.iter()
.any(|name| name == target)
{
return Ok(true);
}
if worktree_detached_operation_uses_ref(git_dir, target) {
return Ok(true);
}
Ok(false)
}
fn worktree_detached_operation_uses_ref(git_dir: &Path, target: &str) -> bool {
worktree_detached_operation_refs(git_dir)
.iter()
.any(|name| name == target)
}
fn worktree_detached_operation_refs(git_dir: &Path) -> Vec<String> {
let mut refs = Vec::new();
for dir in ["rebase-merge", "rebase-apply"] {
let Some(refname) = operation_head_name_ref(git_dir.join(dir).join("head-name")) else {
continue;
};
refs.push(refname);
}
refs.extend(worktree_rebase_update_refs(git_dir));
if let Some(refname) = operation_head_name_ref(git_dir.join("BISECT_START")) {
refs.push(refname);
}
refs
}
fn worktree_rebase_update_refs(git_dir: &Path) -> Vec<String> {
let Ok(text) = fs::read_to_string(git_dir.join("rebase-merge").join("update-refs")) else {
return Vec::new();
};
text.lines()
.step_by(3)
.filter_map(|line| {
let line = line.trim();
(!line.is_empty()).then(|| line.to_string())
})
.collect()
}
fn operation_head_name_ref(path: PathBuf) -> Option<String> {
let value = fs::read_to_string(path).ok()?;
let value = value.trim();
if value.is_empty() {
return None;
}
if value.starts_with("refs/heads/") {
Some(value.to_string())
} else {
Some(format!("refs/heads/{value}"))
}
}
fn resolve_worktree_admin_path(admin_dir: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
admin_dir.join(path)
}
}
pub fn is_shallow_repository(git_dir: &Path) -> bool {
git_dir.join("shallow").exists()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemoveOptions {
pub recursive: bool,
pub cached: bool,
pub force: bool,
pub dry_run: bool,
pub ignore_unmatch: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoveOptions {
pub force: bool,
pub dry_run: bool,
pub skip_errors: bool,
}
impl ShortStatusEntry {
pub fn as_row(&self) -> ShortStatusRow<'_> {
ShortStatusRow {
index: self.index,
worktree: self.worktree,
path: &self.path,
head_mode: self.head_mode,
index_mode: self.index_mode,
worktree_mode: self.worktree_mode,
head_oid: self.head_oid,
index_oid: self.index_oid,
submodule: self.submodule,
}
}
pub fn line(&self) -> String {
format!(
"{}{} {}",
self.index as char,
self.worktree as char,
String::from_utf8_lossy(&self.path)
)
}
}
impl ShortStatusRow<'_> {
pub fn to_owned_entry(self) -> ShortStatusEntry {
ShortStatusEntry {
index: self.index,
worktree: self.worktree,
path: self.path.to_vec(),
head_mode: self.head_mode,
index_mode: self.index_mode,
worktree_mode: self.worktree_mode,
head_oid: self.head_oid,
index_oid: self.index_oid,
submodule: self.submodule,
}
}
pub fn line(&self) -> String {
format!(
"{}{} {}",
self.index as char,
self.worktree as char,
String::from_utf8_lossy(self.path)
)
}
}
pub fn add_paths_to_index(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<UpdateIndexResult> {
update_index_paths(
worktree_root,
git_dir,
format,
paths,
UpdateIndexOptions {
add: true,
remove: false,
force_remove: false,
chmod: None,
info_only: false,
ignore_skip_worktree_entries: false,
allow_skip_worktree_entries: false,
},
)
}
pub fn update_index_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
options: UpdateIndexOptions,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
update_index_paths_with_index(worktree_root, git_dir, format, index, paths, options)
}
pub fn update_index_paths_with_index(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
index: Index,
paths: &[PathBuf],
options: UpdateIndexOptions,
) -> Result<UpdateIndexResult> {
let ordered = ordered_paths_from_plain(paths, options);
update_index_paths_impl(
worktree_root.as_ref(),
git_dir.as_ref(),
format,
index,
&ordered,
options,
None,
false,
)
}
fn ordered_paths_from_plain(
paths: &[PathBuf],
options: UpdateIndexOptions,
) -> Vec<UpdateIndexPath> {
let mode = options.path_mode();
paths
.iter()
.map(|path| UpdateIndexPath {
path: path.clone(),
mode,
})
.collect()
}
pub fn update_index_ordered_paths_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[UpdateIndexPath],
options: UpdateIndexOptions,
config: &GitConfig,
verbose: bool,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
update_index_ordered_paths_filtered_with_index(
worktree_root,
git_dir,
format,
index,
paths,
options,
config,
verbose,
)
}
pub fn update_index_ordered_paths_filtered_with_index(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
index: Index,
paths: &[UpdateIndexPath],
options: UpdateIndexOptions,
config: &GitConfig,
verbose: bool,
) -> Result<UpdateIndexResult> {
update_index_paths_impl(
worktree_root.as_ref(),
git_dir.as_ref(),
format,
index,
paths,
options,
Some(config),
verbose,
)
}
pub fn add_paths_to_index_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
config: &GitConfig,
) -> Result<UpdateIndexResult> {
update_index_paths_filtered(
worktree_root,
git_dir,
format,
paths,
UpdateIndexOptions {
add: true,
remove: false,
force_remove: false,
chmod: None,
info_only: false,
ignore_skip_worktree_entries: false,
allow_skip_worktree_entries: false,
},
config,
)
}
pub fn update_index_paths_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
options: UpdateIndexOptions,
config: &GitConfig,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
update_index_paths_filtered_with_index(
worktree_root,
git_dir,
format,
index,
paths,
options,
config,
)
}
pub fn update_index_paths_filtered_with_index(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
index: Index,
paths: &[PathBuf],
options: UpdateIndexOptions,
config: &GitConfig,
) -> Result<UpdateIndexResult> {
let ordered = ordered_paths_from_plain(paths, options);
update_index_paths_impl(
worktree_root.as_ref(),
git_dir.as_ref(),
format,
index,
&ordered,
options,
Some(config),
false,
)
}
pub fn add_update_all_tracked_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
clean_config: &GitConfig,
) -> Result<Vec<AddUpdateTrackedAction>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(Vec::new());
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
let index_mtime = fs::metadata(&index_path)
.ok()
.and_then(|metadata| file_mtime_parts(&metadata));
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
let prechecks =
tracked_only_non_clean_prechecks_parallel(worktree_root, &index, &stat_cache, false)?;
if prechecks.is_empty() {
return Ok(Vec::new());
}
let pending = prechecks
.into_iter()
.map(|precheck| match precheck {
TrackedOnlyPrecheck::Deleted(idx) => {
(precheck, index.entries[idx].path.as_bytes().to_vec())
}
TrackedOnlyPrecheck::Slow(idx) => {
(precheck, index.entries[idx].path.as_bytes().to_vec())
}
})
.collect::<Vec<_>>();
let odb = FileObjectDatabase::from_git_dir(git_dir, format);
let mut actions = Vec::new();
let mut index_dirty = false;
let mut clean_filter = None;
let trust_filemode = trust_executable_bit(clean_config);
for (precheck, path) in pending {
match precheck {
TrackedOnlyPrecheck::Deleted(_) => {
if remove_index_entries_with_path(&mut index.entries, &path) {
actions.push(AddUpdateTrackedAction::Remove(path));
index_dirty = true;
}
}
TrackedOnlyPrecheck::Slow(_) => {
let (action, dirty) = add_update_tracked_path(
worktree_root,
git_dir,
format,
Some(clean_config),
trust_filemode,
&odb,
&stat_cache,
&mut clean_filter,
&mut index,
&path,
)?;
index_dirty |= dirty;
if let Some(action) = action {
actions.push(action);
}
}
}
}
if index_dirty {
normalize_index_version_for_extended_flags(&mut index);
index.extensions = index_extensions_without_cache_tree(&index.extensions);
write_repository_index_ref(git_dir, format, &index)?;
}
Ok(actions)
}
pub fn add_exact_tracked_path_from_disk(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
git_path: &[u8],
ignore_removal: bool,
config_parameters_env: Option<&str>,
) -> Result<AddExactTrackedPathResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index_metadata = match fs::metadata(&index_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(AddExactTrackedPathResult::Unsupported);
}
Err(err) => return Err(err.into()),
};
let mut index_bytes = fs::read(&index_path)?;
let Some(raw) = raw_exact_index_entry(&index_bytes, format, git_path)? else {
return Ok(AddExactTrackedPathResult::Unsupported);
};
if !raw_exact_entry_can_patch(&raw, git_path) {
return Ok(AddExactTrackedPathResult::Unsupported);
}
if !raw_index_extensions_are_filterable(&index_bytes, raw.entries_end, raw.checksum_offset) {
return Ok(AddExactTrackedPathResult::Unsupported);
}
let entry = raw.entry.clone();
if entry.stage() != Stage::Normal
|| index_entry_skip_worktree(&entry)
|| sley_index::is_gitlink(entry.mode)
{
return Ok(AddExactTrackedPathResult::Unsupported);
}
let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(if ignore_removal {
AddExactTrackedPathResult::Handled(None)
} else {
AddExactTrackedPathResult::Unsupported
});
}
Err(err) => return Err(err.into()),
};
let file_type = metadata.file_type();
if metadata.is_dir() || !(file_type.is_file() || file_type.is_symlink()) {
return Ok(AddExactTrackedPathResult::Unsupported);
}
let index_mtime = file_mtime_parts(&index_metadata);
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
if stat_cache.reuse_index_entry(&entry, &metadata).is_some() {
return Ok(AddExactTrackedPathResult::Handled(None));
}
let odb = FileObjectDatabase::from_git_dir(git_dir, format);
let is_symlink = file_type.is_symlink();
let body = if is_symlink {
symlink_target_bytes(&absolute)?
} else {
let body = fs::read(&absolute)?;
let config =
sley_config::read_repo_config(git_dir, config_parameters_env).unwrap_or_default();
let mut clean_filter = None;
let clean_filter =
tracked_only_clean_filter_with_config(&mut clean_filter, worktree_root, &config);
clean_filter.read_attributes_for_path(worktree_root, git_path)?;
let checks =
clean_filter
.matcher
.attributes_for_path(git_path, &clean_filter.requested, false);
let conv_flags = ConvFlags::from_config(&clean_filter.config);
let index_blob = match conv_flags {
ConvFlags::Off => SafeCrlfIndexBlob::None,
_ => SafeCrlfIndexBlob::Lookup {
odb: &odb,
oid: entry.oid,
},
};
apply_clean_filter_with_attributes_cow_safecrlf(
&clean_filter.config,
&checks,
git_path,
&body,
conv_flags,
index_blob,
)?
.into_owned()
};
let object = EncodedObject::new(ObjectType::Blob, body);
let oid = object.object_id(format)?;
if oid != entry.oid || entry.is_intent_to_add() {
odb.write_object(object)?;
}
let config = sley_config::read_repo_config(git_dir, config_parameters_env).unwrap_or_default();
let trust_filemode = trust_executable_bit(&config);
let mut updated_entry =
index_entry_from_metadata_with_filemode(entry.path.clone(), oid, &metadata, trust_filemode);
if is_symlink {
updated_entry.mode = 0o120000;
}
if updated_entry == entry {
return Ok(AddExactTrackedPathResult::Handled(None));
}
if !raw_updated_entry_can_patch(&entry, &updated_entry, git_path) {
return Ok(AddExactTrackedPathResult::Unsupported);
}
patch_raw_index_entry(&mut index_bytes, format, &raw, &updated_entry)?;
fs::write(index_path, index_bytes)?;
let changed = updated_entry.oid != entry.oid || updated_entry.mode != entry.mode;
Ok(AddExactTrackedPathResult::Handled(
changed.then(|| AddUpdateTrackedAction::Add(git_path.to_vec())),
))
}
pub fn add_exact_tracked_path_with_index(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
mut index: Index,
git_path: &[u8],
) -> Result<Option<AddUpdateTrackedAction>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let range = index_entries_path_range(&index.entries, git_path);
if range.len() != 1 {
return Ok(None);
}
let entry = &index.entries[range.start];
if entry.stage() != Stage::Normal || index_entry_skip_worktree(entry) {
return Ok(None);
}
let index_path = repository_index_path(git_dir);
let index_mtime = fs::metadata(&index_path)
.ok()
.and_then(|metadata| file_mtime_parts(&metadata));
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
let odb = FileObjectDatabase::from_git_dir(git_dir, format);
let trust_filemode = trust_executable_bit_from_git_dir(git_dir, None);
let mut clean_filter = None;
let (action, dirty) = add_update_tracked_path(
worktree_root,
git_dir,
format,
None,
trust_filemode,
&odb,
&stat_cache,
&mut clean_filter,
&mut index,
git_path,
)?;
if dirty {
normalize_index_version_for_extended_flags(&mut index);
index.extensions = index_extensions_without_cache_tree(&index.extensions);
write_repository_index_ref(git_dir, format, &index)?;
}
Ok(action)
}
struct RawExactIndexEntry {
version: u32,
entry: IndexEntry,
entry_start: usize,
entries_end: usize,
checksum_offset: usize,
}
fn raw_exact_index_entry(
bytes: &[u8],
format: ObjectFormat,
git_path: &[u8],
) -> Result<Option<RawExactIndexEntry>> {
let hash_len = format.raw_len();
if bytes.len() < 12 + hash_len {
return Err(GitError::InvalidFormat("index header too short".into()));
}
let checksum_offset = bytes.len() - hash_len;
let actual_checksum = sley_core::digest_bytes(format, &bytes[..checksum_offset])?;
let expected_checksum = ObjectId::from_raw(format, &bytes[checksum_offset..])?;
if actual_checksum != expected_checksum {
return Err(GitError::InvalidFormat(format!(
"index checksum mismatch: expected {expected_checksum}, got {actual_checksum}"
)));
}
if &bytes[..4] != b"DIRC" {
return Err(GitError::InvalidFormat("missing DIRC signature".into()));
}
let version = u32_from_be(&bytes[4..8]);
if !(2..=3).contains(&version) {
return Ok(None);
}
let count = u32_from_be(&bytes[8..12]) as usize;
let mut offset = 12;
let mut found = None;
for _ in 0..count {
let entry_header_len = 40 + hash_len + 2;
if checksum_offset.saturating_sub(offset) < entry_header_len {
return Err(GitError::InvalidFormat("truncated index entry".into()));
}
let start = offset;
let oid_start = offset + 40;
let oid_end = oid_start + hash_len;
let flags = u16_from_be(&bytes[oid_end..oid_end + 2]);
offset = oid_end + 2;
let flags_extended = if flags & INDEX_FLAG_EXTENDED != 0 {
if checksum_offset.saturating_sub(offset) < 2 {
return Err(GitError::InvalidFormat(
"truncated index extended flags".into(),
));
}
let flags_extended = u16_from_be(&bytes[offset..offset + 2]);
offset += 2;
flags_extended
} else {
0
};
let path_start = offset;
while bytes.get(offset).copied() != Some(0) {
offset += 1;
if offset >= checksum_offset {
return Err(GitError::InvalidFormat("unterminated index path".into()));
}
}
let path = &bytes[path_start..offset];
offset += 1;
while (offset - start) % 8 != 0 {
offset += 1;
if offset > checksum_offset {
return Err(GitError::InvalidFormat("truncated index padding".into()));
}
}
if path == git_path {
if found.is_some() {
return Ok(None);
}
let oid = ObjectId::from_raw(format, &bytes[oid_start..oid_end])?;
found = Some(RawExactIndexEntry {
version,
entry: IndexEntry {
ctime_seconds: u32_from_be(&bytes[start..start + 4]),
ctime_nanoseconds: u32_from_be(&bytes[start + 4..start + 8]),
mtime_seconds: u32_from_be(&bytes[start + 8..start + 12]),
mtime_nanoseconds: u32_from_be(&bytes[start + 12..start + 16]),
dev: u32_from_be(&bytes[start + 16..start + 20]),
ino: u32_from_be(&bytes[start + 20..start + 24]),
mode: u32_from_be(&bytes[start + 24..start + 28]),
uid: u32_from_be(&bytes[start + 28..start + 32]),
gid: u32_from_be(&bytes[start + 32..start + 36]),
size: u32_from_be(&bytes[start + 36..start + 40]),
oid,
flags,
flags_extended,
path: BString::from(path),
},
entry_start: start,
entries_end: 0,
checksum_offset,
});
} else if found.is_none() && path > git_path {
return Ok(None);
}
}
if let Some(mut found) = found {
found.entries_end = offset;
Ok(Some(found))
} else {
Ok(None)
}
}
fn raw_exact_entry_can_patch(raw: &RawExactIndexEntry, git_path: &[u8]) -> bool {
raw.version == 2
&& raw.entry.flags_extended == 0
&& raw.entry.flags & INDEX_FLAG_EXTENDED == 0
&& raw.entry.flags == index_flags(git_path.len(), 0)
&& raw.entry.path.as_bytes() == git_path
}
fn raw_updated_entry_can_patch(
previous: &IndexEntry,
updated: &IndexEntry,
git_path: &[u8],
) -> bool {
updated.path.as_bytes() == git_path
&& updated.flags_extended == 0
&& updated.flags & INDEX_FLAG_EXTENDED == 0
&& updated.flags == previous.flags
}
fn raw_index_extensions_are_filterable(
bytes: &[u8],
entries_end: usize,
checksum_offset: usize,
) -> bool {
let mut offset = entries_end;
while offset < checksum_offset {
if checksum_offset.saturating_sub(offset) < 8 {
return false;
}
let size = u32_from_be(&bytes[offset + 4..offset + 8]) as usize;
let Some(end) = offset
.checked_add(8)
.and_then(|offset| offset.checked_add(size))
else {
return false;
};
if end > checksum_offset {
return false;
}
offset = end;
}
true
}
fn patch_raw_index_entry(
bytes: &mut Vec<u8>,
format: ObjectFormat,
raw: &RawExactIndexEntry,
entry: &IndexEntry,
) -> Result<()> {
let hash_len = format.raw_len();
let start = raw.entry_start;
bytes[start..start + 4].copy_from_slice(&entry.ctime_seconds.to_be_bytes());
bytes[start + 4..start + 8].copy_from_slice(&entry.ctime_nanoseconds.to_be_bytes());
bytes[start + 8..start + 12].copy_from_slice(&entry.mtime_seconds.to_be_bytes());
bytes[start + 12..start + 16].copy_from_slice(&entry.mtime_nanoseconds.to_be_bytes());
bytes[start + 16..start + 20].copy_from_slice(&entry.dev.to_be_bytes());
bytes[start + 20..start + 24].copy_from_slice(&entry.ino.to_be_bytes());
bytes[start + 24..start + 28].copy_from_slice(&entry.mode.to_be_bytes());
bytes[start + 28..start + 32].copy_from_slice(&entry.uid.to_be_bytes());
bytes[start + 32..start + 36].copy_from_slice(&entry.gid.to_be_bytes());
bytes[start + 36..start + 40].copy_from_slice(&entry.size.to_be_bytes());
bytes[start + 40..start + 40 + hash_len].copy_from_slice(entry.oid.as_bytes());
bytes[start + 40 + hash_len..start + 40 + hash_len + 2]
.copy_from_slice(&entry.flags.to_be_bytes());
let mut extension_offset = raw.entries_end;
let mut removed_cache_tree = false;
let mut rewritten = Vec::new();
while extension_offset < raw.checksum_offset {
let signature = &bytes[extension_offset..extension_offset + 4];
let size = u32_from_be(&bytes[extension_offset + 4..extension_offset + 8]) as usize;
let end = extension_offset + 8 + size;
if signature == b"TREE" {
removed_cache_tree = true;
} else {
rewritten.extend_from_slice(&bytes[extension_offset..end]);
}
extension_offset = end;
}
if removed_cache_tree {
bytes.truncate(raw.entries_end);
bytes.extend_from_slice(&rewritten);
let checksum = sley_core::digest_bytes(format, bytes)?;
bytes.extend_from_slice(checksum.as_bytes());
} else {
let checksum = sley_core::digest_bytes(format, &bytes[..raw.checksum_offset])?;
bytes[raw.checksum_offset..raw.checksum_offset + hash_len]
.copy_from_slice(checksum.as_bytes());
}
Ok(())
}
fn u32_from_be(bytes: &[u8]) -> u32 {
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
}
fn u16_from_be(bytes: &[u8]) -> u16 {
u16::from_be_bytes([bytes[0], bytes[1]])
}
fn add_update_tracked_path(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
clean_config: Option<&GitConfig>,
trust_filemode: bool,
odb: &FileObjectDatabase,
stat_cache: &IndexStatCache,
clean_filter: &mut Option<TrackedOnlyCleanFilter>,
index: &mut Index,
git_path: &[u8],
) -> Result<(Option<AddUpdateTrackedAction>, bool)> {
let range = index_entries_path_range(&index.entries, git_path);
if range.is_empty() {
return Ok((None, false));
}
let entry = index.entries[range.start].clone();
if entry.stage() != Stage::Normal {
return Ok((None, false));
}
let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
if remove_index_entries_with_path(&mut index.entries, git_path) {
return Ok((
Some(AddUpdateTrackedAction::Remove(git_path.to_vec())),
true,
));
}
return Ok((None, false));
}
Err(err) => return Err(err.into()),
};
if metadata.is_dir() {
if !sley_index::is_gitlink(entry.mode) {
return Ok((None, false));
}
let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(entry.oid);
let mut updated_entry = index_entry_from_metadata_with_filemode(
entry.path.clone(),
oid,
&metadata,
trust_filemode,
);
updated_entry.mode = sley_index::GITLINK_MODE;
let changed = updated_entry.oid != entry.oid || updated_entry.mode != entry.mode;
if updated_entry != entry {
replace_index_entries_with_entry(&mut index.entries, updated_entry);
return Ok((
changed.then(|| AddUpdateTrackedAction::Add(git_path.to_vec())),
true,
));
}
return Ok((None, false));
}
if !(metadata.is_file() || metadata.file_type().is_symlink()) {
return Ok((None, false));
}
if stat_cache.reuse_index_entry(&entry, &metadata).is_some() {
return Ok((None, false));
}
let is_symlink = metadata.file_type().is_symlink();
let body = if is_symlink {
symlink_target_bytes(&absolute)?
} else {
let body = fs::read(&absolute)?;
let clean_filter = match clean_config {
Some(config) => {
tracked_only_clean_filter_with_config(clean_filter, worktree_root, config)
}
None => tracked_only_clean_filter(clean_filter, worktree_root, git_dir),
};
clean_filter.read_attributes_for_path(worktree_root, git_path)?;
let checks =
clean_filter
.matcher
.attributes_for_path(git_path, &clean_filter.requested, false);
let conv_flags = ConvFlags::from_config(&clean_filter.config);
let index_blob = match conv_flags {
ConvFlags::Off => SafeCrlfIndexBlob::None,
_ => SafeCrlfIndexBlob::Lookup {
odb,
oid: entry.oid,
},
};
apply_clean_filter_with_attributes_cow_safecrlf(
&clean_filter.config,
&checks,
git_path,
&body,
conv_flags,
index_blob,
)?
.into_owned()
};
let object = EncodedObject::new(ObjectType::Blob, body);
let oid = object.object_id(format)?;
if oid != entry.oid || entry.is_intent_to_add() {
odb.write_object(object)?;
}
let mut updated_entry =
index_entry_from_metadata_with_filemode(entry.path.clone(), oid, &metadata, trust_filemode);
if is_symlink {
updated_entry.mode = 0o120000;
}
let changed = updated_entry.oid != entry.oid || updated_entry.mode != entry.mode;
if updated_entry != entry {
replace_index_entries_with_entry(&mut index.entries, updated_entry);
return Ok((
changed.then(|| AddUpdateTrackedAction::Add(git_path.to_vec())),
true,
));
}
Ok((None, false))
}
enum UpdateIndexCleanFilter {
Full(AttributeMatcher),
PathLocal,
}
fn index_entries_path_range(entries: &[IndexEntry], path: &[u8]) -> std::ops::Range<usize> {
let mut start = match entries.binary_search_by(|entry| entry.path.as_bytes().cmp(path)) {
Ok(index) => index,
Err(insert) => return insert..insert,
};
while start > 0 && entries[start - 1].path.as_bytes() == path {
start -= 1;
}
let mut end = start;
while end < entries.len() && entries[end].path.as_bytes() == path {
end += 1;
}
start..end
}
fn remove_index_entries_with_path(entries: &mut Vec<IndexEntry>, path: &[u8]) -> bool {
let range = index_entries_path_range(entries, path);
if range.is_empty() {
return false;
}
entries.drain(range);
true
}
fn remove_index_entries_under_dir(entries: &mut Vec<IndexEntry>, name: &[u8]) {
let start = match entries.binary_search_by(|entry| entry.path.as_bytes().cmp(name)) {
Ok(found) => found + 1,
Err(insert) => insert,
};
let mut end = start;
while end < entries.len() {
let candidate = entries[end].path.as_bytes();
if candidate.len() > name.len()
&& candidate[name.len()] == b'/'
&& candidate[..name.len()] == *name
{
end += 1;
} else {
break;
}
}
if end > start {
entries.drain(start..end);
}
}
fn remove_index_dir_name_conflicts(entries: &mut Vec<IndexEntry>, name: &[u8]) {
let mut slash = name.len();
while let Some(pos) = name[..slash].iter().rposition(|&byte| byte == b'/') {
slash = pos;
let prefix = &name[..slash];
match entries.binary_search_by(|entry| entry.path.as_bytes().cmp(prefix)) {
Ok(found) => {
entries.remove(found);
}
Err(insert) => {
if insert < entries.len() {
let candidate = entries[insert].path.as_bytes();
if candidate.len() > prefix.len()
&& candidate[prefix.len()] == b'/'
&& candidate[..prefix.len()] == *prefix
{
break;
}
}
}
}
}
}
fn replace_index_entries_with_entry(entries: &mut Vec<IndexEntry>, entry: IndexEntry) {
let path = entry.path.as_bytes().to_vec();
remove_index_entries_under_dir(entries, &path);
remove_index_dir_name_conflicts(entries, &path);
let range = index_entries_path_range(entries, &path);
if range.is_empty() {
entries.insert(range.start, entry);
} else {
entries.splice(range, [entry]);
}
}
fn write_index_blob_object(
odb: &FileObjectDatabase,
format: ObjectFormat,
object: EncodedObject,
large_policy: LargeObjectPolicy,
pending_large: &mut Vec<(ObjectId, EncodedObject)>,
) -> Result<ObjectId> {
let oid = object.object_id(format)?;
if object.object_type == ObjectType::Blob && object.body.len() as u64 >= large_policy.threshold
{
if !odb.contains(&oid)? {
pending_large.push((oid, object));
}
return Ok(oid);
}
odb.write_object(object)
}
fn write_pending_large_blobs(
odb: &FileObjectDatabase,
objects: &[(ObjectId, EncodedObject)],
policy: LargeObjectPolicy,
) -> Result<()> {
let Some(limit) = policy.pack_size_limit else {
return odb.write_blobs_as_pack(objects, policy.compression_level);
};
let mut start = 0usize;
let mut current_size = 0u64;
for (idx, (_, object)) in objects.iter().enumerate() {
let estimate = object.body.len() as u64 + 32;
if idx > start && current_size.saturating_add(estimate) > limit {
odb.write_blobs_as_pack(&objects[start..idx], policy.compression_level)?;
start = idx;
current_size = 0;
}
current_size = current_size.saturating_add(estimate);
}
if start < objects.len() {
odb.write_blobs_as_pack(&objects[start..], policy.compression_level)?;
}
Ok(())
}
fn update_index_paths_impl(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
mut index: Index,
paths: &[UpdateIndexPath],
options: UpdateIndexOptions,
clean_config: Option<&GitConfig>,
verbose: bool,
) -> Result<UpdateIndexResult> {
let odb = FileObjectDatabase::from_git_dir(git_dir, format);
let mut large_policy = LargeObjectPolicy::from_config(git_dir, None)?;
if let Some(config) = clean_config {
large_policy.compression_level = pack_compression_level(config);
large_policy.pack_size_limit = config
.get("pack", None, "packSizeLimit")
.and_then(sley_config::parse_config_int)
.and_then(|value| (value > 0).then_some(value as u64))
.or(large_policy.pack_size_limit);
}
let trust_filemode = clean_config
.map(trust_executable_bit)
.unwrap_or_else(|| trust_executable_bit_from_git_dir(git_dir, None));
let trust_symlinks = clean_config
.map(trust_symlinks)
.unwrap_or_else(|| trust_symlinks_from_git_dir(git_dir, None));
if options.allow_skip_worktree_entries {
expand_sparse_index(&mut index, &odb, format)?;
}
let sparse_checkout_active = sparse_checkout_config_enabled(git_dir)
|| index.is_sparse()
|| index.entries.iter().any(IndexEntry::is_sparse_dir);
let clean_filter = match clean_config {
Some(_) if paths.len() >= 64 => Some(UpdateIndexCleanFilter::Full(
AttributeMatcher::from_worktree_root(worktree_root)?,
)),
Some(_) => Some(UpdateIndexCleanFilter::PathLocal),
None => None,
};
let conv_flags = clean_config.map_or(ConvFlags::Off, ConvFlags::from_config);
let non_atomic_chmod_errors = clean_config.is_some() && options.add && options.remove;
let requested_filter_attrs = filter_attribute_names();
let mut updated = Vec::new();
let mut reports: Vec<String> = Vec::new();
let mut untracked_cache_invalidation_paths = Vec::new();
let mut pending_large = Vec::new();
let mut chmod_error = false;
for update_path in paths {
let path = &update_path.path;
let path_mode = update_path.mode;
let path_chmod = path_mode.chmod;
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let absolute = normalize_absolute_path_lexically(&absolute);
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if index_sparse_dir_contains_path(&index, &git_path) {
expand_sparse_index(&mut index, &odb, format)?;
}
let existing_range = index_entries_path_range(&index.entries, &git_path);
if path_mode.force_remove {
record_resolve_undo_for_range(&mut index, format, &git_path, existing_range)?;
remove_index_entries_with_path(&mut index.entries, &git_path);
untracked_cache_invalidation_paths.push(git_path.clone());
reports.push(format!("remove '{}'", String::from_utf8_lossy(&git_path)));
continue;
}
let symlink_metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => Some(metadata),
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
None
}
Err(err) => return Err(err.into()),
};
if !options.allow_skip_worktree_entries
&& index.entries[existing_range.clone()]
.iter()
.any(index_entry_skip_worktree)
{
if path_mode.remove {
if !options.ignore_skip_worktree_entries {
index.entries.drain(existing_range);
}
continue;
}
if symlink_metadata.is_none()
|| options.ignore_skip_worktree_entries
|| !sparse_checkout_active
{
continue;
}
}
let Some(metadata) = symlink_metadata else {
if path_mode.remove {
record_resolve_undo_for_range(&mut index, format, &git_path, existing_range)?;
remove_index_entries_with_path(&mut index.entries, &git_path);
untracked_cache_invalidation_paths.push(git_path.clone());
reports.push(format!("add '{}'", String::from_utf8_lossy(&git_path)));
continue;
}
print_update_index_path_error(&git_path, "does not exist and --remove not passed");
return Err(GitError::Exit(128));
};
if !path_mode.add && index_entries_path_range(&index.entries, &git_path).is_empty() {
print_update_index_path_error(
&git_path,
"cannot add to the index - missing --add option?",
);
return Err(GitError::Exit(128));
}
if metadata.is_dir() {
if path_mode.remove
&& !existing_range.is_empty()
&& sley_diff_merge::gitlink_head_oid(&absolute, format).is_none()
{
record_resolve_undo_for_range(
&mut index,
format,
&git_path,
existing_range.clone(),
)?;
remove_index_entries_with_path(&mut index.entries, &git_path);
untracked_cache_invalidation_paths.push(git_path.clone());
reports.push(format!("add '{}'", String::from_utf8_lossy(&git_path)));
continue;
}
let display = String::from_utf8_lossy(&git_path).into_owned();
let has_dot_git = absolute.join(".git").exists();
if let Some(submodule_format) = embedded_repo_object_format(&absolute)
&& submodule_format != format
{
eprintln!("fatal: cannot add a submodule of a different hash algorithm");
return Err(GitError::Exit(128));
}
let Some(head_oid) = sley_diff_merge::gitlink_head_oid(&absolute, format) else {
if has_dot_git {
if clean_config.is_some() {
let display_dir = if display.ends_with('/') {
display.clone()
} else {
format!("{display}/")
};
eprintln!("error: '{display_dir}' does not have a commit checked out");
eprintln!("error: unable to index file '{display_dir}'");
eprintln!("fatal: adding files failed");
} else {
eprintln!("error: '{display}' does not have a commit checked out");
eprintln!("fatal: Unable to process path {display}");
}
} else {
eprintln!("error: {display}: is a directory - add files inside instead");
eprintln!("fatal: Unable to process path {display}");
}
return Err(GitError::Exit(128));
};
if path_chmod.is_some() {
eprintln!(
"fatal: git update-index: cannot chmod {}x '{display}'",
if path_chmod == Some(true) { '+' } else { '-' },
);
return Err(GitError::Exit(128));
}
let mut entry = index_entry_from_metadata_with_filemode(
git_path.clone(),
head_oid,
&metadata,
trust_filemode,
);
entry.mode = sley_index::GITLINK_MODE;
reports.push(format!("add '{display}'"));
record_resolve_undo_for_range(&mut index, format, &git_path, existing_range.clone())?;
replace_index_entries_with_entry(&mut index.entries, entry);
untracked_cache_invalidation_paths.push(git_path.clone());
updated.push(head_oid);
continue;
}
let is_symlink = metadata.file_type().is_symlink();
let body = if is_symlink {
symlink_target_bytes(&absolute)?
} else {
let body = fs::read(&absolute)?;
let index_blob = match conv_flags {
ConvFlags::Off => SafeCrlfIndexBlob::None,
_ => stage0_oid_in_range(&index.entries, existing_range.clone()).map_or(
SafeCrlfIndexBlob::None,
|oid| SafeCrlfIndexBlob::Lookup { odb: &odb, oid },
),
};
match (clean_config, &clean_filter) {
(Some(config), Some(UpdateIndexCleanFilter::Full(matcher))) => {
let checks =
matcher.attributes_for_path(&git_path, &requested_filter_attrs, false);
apply_clean_filter_with_attributes_cow_safecrlf(
config, &checks, &git_path, &body, conv_flags, index_blob,
)?
.into_owned()
}
(Some(config), Some(UpdateIndexCleanFilter::PathLocal)) => {
let checks = filter_attribute_checks(worktree_root, &git_path)?;
apply_clean_filter_with_attributes_cow_safecrlf(
config, &checks, &git_path, &body, conv_flags, index_blob,
)?
.into_owned()
}
_ => body,
}
};
let object = EncodedObject::new(ObjectType::Blob, body);
let oid = if path_mode.info_only {
object.object_id(format)?
} else {
write_index_blob_object(&odb, format, object, large_policy, &mut pending_large)?
};
let mut entry = index_entry_from_metadata_with_filemode(
git_path.clone(),
oid,
&metadata,
trust_filemode,
);
if is_symlink {
entry.mode = 0o120000;
}
if let Some(mode) = preferred_unmerged_mode_for_untrusted_worktree(
&index.entries[existing_range.clone()],
trust_filemode,
trust_symlinks,
) {
entry.mode = mode;
}
reports.push(format!("add '{}'", String::from_utf8_lossy(&git_path)));
if let Some(executable) = path_chmod {
if is_symlink {
eprintln!(
"fatal: git update-index: cannot chmod {}x '{}'",
if executable { '+' } else { '-' },
String::from_utf8_lossy(&git_path)
);
if !non_atomic_chmod_errors {
return Err(GitError::Exit(128));
}
chmod_error = true;
} else {
entry.mode = if executable { 0o100755 } else { 0o100644 };
reports.push(format!(
"chmod {}x '{}'",
if executable { '+' } else { '-' },
String::from_utf8_lossy(&git_path)
));
}
}
record_resolve_undo_for_range(&mut index, format, &git_path, existing_range.clone())?;
replace_index_entries_with_entry(&mut index.entries, entry);
untracked_cache_invalidation_paths.push(git_path);
updated.push(oid);
}
normalize_index_version_for_extended_flags(&mut index);
index.extensions = index_extensions_without_cache_tree(&index.extensions);
invalidate_untracked_cache_for_git_paths(
&mut index,
format,
&untracked_cache_invalidation_paths,
)?;
if !pending_large.is_empty() {
write_pending_large_blobs(&odb, &pending_large, large_policy)?;
}
write_repository_index_ref(git_dir, format, &index)?;
if verbose {
let mut stdout = std::io::stdout().lock();
for line in &reports {
writeln!(stdout, "{line}")?;
}
stdout.flush()?;
}
if chmod_error {
return Err(GitError::Exit(128));
}
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated,
})
}
pub fn refresh_index_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
quiet: bool,
ignore_missing: bool,
really_refresh: bool,
) -> Result<UpdateIndexResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(UpdateIndexResult {
entries: 0,
updated: Vec::new(),
});
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
let trust_filemode = trust_executable_bit_from_git_dir(git_dir, None);
let index_mtime = fs::metadata(&index_path)
.ok()
.and_then(|metadata| file_mtime_parts(&metadata));
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
let selected_paths = paths
.iter()
.map(|path| {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
git_path_bytes(relative)
})
.collect::<Result<Vec<_>>>()?;
let selected_paths = selected_paths.into_iter().collect::<BTreeSet<_>>();
if selected_paths.is_empty()
&& !really_refresh
&& !index
.entries
.iter()
.any(|entry| entry.flags & INDEX_FLAG_ASSUME_UNCHANGED != 0)
{
return refresh_all_index_paths_parallel(
worktree_root,
git_dir,
format,
index,
stat_cache,
quiet,
ignore_missing,
trust_filemode,
);
}
let mut needs_update = false;
let mut index_dirty = false;
for entry in &mut index.entries {
if index_entry_stage(entry) != 0 {
continue;
}
if entry.flags & INDEX_FLAG_ASSUME_UNCHANGED != 0 {
if !really_refresh {
continue;
}
entry.flags &= !INDEX_FLAG_ASSUME_UNCHANGED;
index_dirty = true;
}
let absolute = worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?);
let Ok(metadata) = fs::metadata(&absolute) else {
if ignore_missing {
continue;
}
if !quiet {
print_update_index_needs_update(entry.path.as_bytes());
}
needs_update = true;
continue;
};
if sley_index::is_gitlink(entry.mode) {
match sley_index::gitlink_stat_verdict(&metadata) {
sley_index::GitlinkStatVerdict::Populated => continue,
sley_index::GitlinkStatVerdict::TypeChanged => {
if !quiet {
print_update_index_needs_update(entry.path.as_bytes());
}
needs_update = true;
continue;
}
}
}
if !metadata.is_file() {
if !quiet {
print_update_index_needs_update(entry.path.as_bytes());
}
needs_update = true;
continue;
}
if stat_cache.reuse_index_entry(entry, &metadata).is_some() {
continue;
}
let body = fs::read(&absolute)?;
let object = EncodedObject::new(ObjectType::Blob, body);
let oid = object.object_id(format)?;
if oid != entry.oid || file_mode_with_trust(&metadata, trust_filemode) != entry.mode {
if !quiet {
print_update_index_needs_update(entry.path.as_bytes());
}
needs_update = true;
if really_refresh
&& !selected_paths.is_empty()
&& selected_paths.contains(entry.path.as_bytes())
{
let updated_entry = index_entry_from_metadata_with_filemode(
entry.path.clone(),
oid,
&metadata,
trust_filemode,
);
if updated_entry != *entry {
*entry = updated_entry;
index_dirty = true;
}
}
continue;
}
let updated_entry = index_entry_from_metadata_with_filemode(
entry.path.clone(),
oid,
&metadata,
trust_filemode,
);
if updated_entry != *entry {
*entry = updated_entry;
index_dirty = true;
}
}
if index_dirty {
write_repository_index_ref(git_dir, format, &index)?;
}
if needs_update && !quiet {
return Err(GitError::Exit(1));
}
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
fn refresh_all_index_paths_parallel(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
mut index: Index,
stat_cache: IndexStatCache,
quiet: bool,
ignore_missing: bool,
trust_filemode: bool,
) -> Result<UpdateIndexResult> {
let prechecks =
tracked_only_non_clean_prechecks_parallel(worktree_root, &index, &stat_cache, false)?;
let mut needs_update = false;
let mut index_dirty = false;
for precheck in prechecks {
match precheck {
TrackedOnlyPrecheck::Deleted(idx) => {
if ignore_missing {
continue;
}
if !quiet {
print_update_index_needs_update(index.entries[idx].path.as_bytes());
}
needs_update = true;
}
TrackedOnlyPrecheck::Slow(idx) => {
let entry = &mut index.entries[idx];
let path = entry.path.as_bytes().to_vec();
let absolute = worktree_root.join(repo_path_to_os_path(&path)?);
let Ok(metadata) = fs::metadata(&absolute) else {
if ignore_missing {
continue;
}
if !quiet {
print_update_index_needs_update(&path);
}
needs_update = true;
continue;
};
if sley_index::is_gitlink(entry.mode) {
match sley_index::gitlink_stat_verdict(&metadata) {
sley_index::GitlinkStatVerdict::Populated => continue,
sley_index::GitlinkStatVerdict::TypeChanged => {
if !quiet {
print_update_index_needs_update(&path);
}
needs_update = true;
continue;
}
}
}
if !metadata.is_file() {
if !quiet {
print_update_index_needs_update(&path);
}
needs_update = true;
continue;
}
if stat_cache.reuse_index_entry(entry, &metadata).is_some() {
continue;
}
let body = fs::read(&absolute)?;
let object = EncodedObject::new(ObjectType::Blob, body);
let oid = object.object_id(format)?;
if oid != entry.oid || file_mode_with_trust(&metadata, trust_filemode) != entry.mode
{
if !quiet {
print_update_index_needs_update(&path);
}
needs_update = true;
continue;
}
let updated_entry = index_entry_from_metadata_with_filemode(
entry.path.clone(),
oid,
&metadata,
trust_filemode,
);
if updated_entry != *entry {
*entry = updated_entry;
index_dirty = true;
}
}
}
}
if index_dirty {
write_repository_index_ref(git_dir, format, &index)?;
}
if needs_update && !quiet {
return Err(GitError::Exit(1));
}
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
pub fn update_index_again(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
options: UpdateIndexOptions,
) -> Result<UpdateIndexResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(UpdateIndexResult {
entries: 0,
updated: Vec::new(),
});
}
let index = Index::parse(&fs::read(&index_path)?, format)?;
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let head_entries = head_tree_entries(git_dir, format, &db)?;
let selected_paths = selected_git_paths(worktree_root, paths)?;
let mut again_paths = Vec::new();
for entry in &index.entries {
if index_entry_stage(entry) != 0 {
continue;
}
if !selected_paths.is_empty() && !git_path_selected(entry.path.as_bytes(), &selected_paths)
{
continue;
}
let differs_from_head = match head_entries.get(entry.path.as_bytes()) {
Some(head_entry) => head_entry.oid != entry.oid || head_entry.mode != entry.mode,
None => true,
};
if differs_from_head {
again_paths.push(worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?));
}
}
if again_paths.is_empty() {
return Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
});
}
update_index_paths(worktree_root, git_dir, format, &again_paths, options)
}
pub fn set_index_assume_unchanged_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
assume_unchanged: bool,
) -> Result<UpdateIndexResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let sparse = active_sparse_checkout(git_dir)?;
let db = FileObjectDatabase::from_git_dir(git_dir, format);
if index.is_sparse() {
expand_sparse_index(&mut index, &db, format)?;
}
let selected_paths = paths
.iter()
.map(|path| {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
git_path_bytes(relative)
})
.collect::<Result<Vec<_>>>()?;
for path in selected_paths {
if let Some(entry) = index.entries.iter_mut().find(|entry| entry.path == path) {
if assume_unchanged {
entry.flags |= INDEX_FLAG_ASSUME_UNCHANGED;
} else {
entry.flags &= !INDEX_FLAG_ASSUME_UNCHANGED;
}
}
}
normalize_index_version_for_extended_flags(&mut index);
if let Some((sparse, mode)) = sparse
&& sparse.sparse_index
{
let matcher = SparseMatcher::new(&sparse, mode);
collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
}
write_repository_index_ref(git_dir, format, &index)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
fn selected_git_paths(worktree_root: &Path, paths: &[PathBuf]) -> Result<BTreeSet<Vec<u8>>> {
paths
.iter()
.map(|path| {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
git_path_bytes(relative)
})
.collect()
}
fn git_path_selected(path: &[u8], selected_paths: &BTreeSet<Vec<u8>>) -> bool {
selected_paths
.iter()
.any(|selected| path == selected || index_entry_is_under_path(path, selected))
}
pub fn set_index_skip_worktree_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
skip_worktree: bool,
) -> Result<UpdateIndexResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let sparse = active_sparse_checkout(git_dir)?;
let db = FileObjectDatabase::from_git_dir(git_dir, format);
if index.is_sparse() {
expand_sparse_index(&mut index, &db, format)?;
}
let selected_paths = paths
.iter()
.map(|path| {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
git_path_bytes(relative)
})
.collect::<Result<Vec<_>>>()?;
for path in selected_paths {
if let Some(entry) = index.entries.iter_mut().find(|entry| entry.path == path) {
if skip_worktree {
entry.flags |= INDEX_FLAG_EXTENDED;
entry.flags_extended |= INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
} else {
entry.flags_extended &= !INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
if entry.flags_extended == 0 {
entry.flags &= !INDEX_FLAG_EXTENDED;
}
}
}
}
normalize_index_version_for_extended_flags(&mut index);
if let Some((sparse, mode)) = sparse
&& sparse.sparse_index
{
let matcher = SparseMatcher::new(&sparse, mode);
collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
}
write_repository_index_ref(git_dir, format, &index)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
pub fn set_index_fsmonitor_valid_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
_fsmonitor_valid: bool,
) -> Result<UpdateIndexResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let selected_paths = paths
.iter()
.map(|path| {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
git_path_bytes(relative)
})
.collect::<Result<Vec<_>>>()?;
for path in selected_paths {
if !index.entries.iter().any(|entry| entry.path == path) {
eprintln!(
"fatal: Unable to mark file {}",
String::from_utf8_lossy(&path)
);
return Err(GitError::Exit(128));
}
}
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
pub fn set_index_version(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
version: u32,
verbose: bool,
) -> Result<UpdateIndexResult> {
if !matches!(version, 2..=4) {
return Err(GitError::Unsupported(format!(
"update-index currently supports --index-version 2, 3, or 4, got {version}"
)));
}
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let previous = index.version;
if verbose {
println!("index-version: was {previous}, set to {version}");
}
index.version = version;
normalize_index_version_for_extended_flags(&mut index);
write_repository_index_ref(git_dir, format, &index)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
pub fn force_write_index(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
normalize_index_version_for_extended_flags(&mut index);
write_repository_index_ref(git_dir, format, &index)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
pub fn enable_untracked_cache(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<()> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
empty_index()
};
let ident = untracked_cache_ident(worktree_root);
let dir_flags = untracked_cache_dir_flags(StatusUntrackedMode::Normal);
let cache = match index.untracked_cache(format)? {
Some(mut cache) if cache.ident == ident => {
cache.dir_flags = dir_flags;
cache
}
_ => UntrackedCache::new(format, ident, dir_flags),
};
index.set_untracked_cache(format, Some(&cache))?;
write_repository_index_ref(git_dir, format, &index)?;
Ok(())
}
pub fn disable_untracked_cache(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<()> {
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(());
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
index.set_untracked_cache(format, None)?;
write_repository_index_ref(git_dir, format, &index)?;
Ok(())
}
pub fn refresh_untracked_cache_after_status(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
config: &GitConfig,
untracked_mode: StatusUntrackedMode,
) -> Result<()> {
if matches!(untracked_mode, StatusUntrackedMode::None) {
return Ok(());
}
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let untracked_cache_setting = config.get("core", None, "untrackedCache");
match untracked_cache_setting {
Some("keep") | None => {
if !repository_index_has_extension(git_dir, format, b"UNTR")? {
return Ok(());
}
}
Some("false" | "no" | "off" | "0") | Some("true" | "yes" | "on" | "1") => {}
Some(_) => {
if !repository_index_has_extension(git_dir, format, b"UNTR")? {
return Ok(());
}
}
}
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
empty_index()
};
match untracked_cache_setting {
Some("false") | Some("no") | Some("off") | Some("0") => {
index.set_untracked_cache(format, None)?;
write_repository_index_ref(git_dir, format, &index)?;
return Ok(());
}
Some("true") | Some("yes") | Some("on") | Some("1") => {}
Some("keep") | None => {
if index.untracked_cache(format)?.is_none() {
return Ok(());
}
}
Some(_) => {
if index.untracked_cache(format)?.is_none() {
return Ok(());
}
}
}
let old_cache = index.untracked_cache(format).ok().flatten();
let ident = untracked_cache_ident(worktree_root);
if old_cache.as_ref().is_some_and(|cache| cache.ident != ident) {
eprintln!("warning: untracked cache is disabled on this system or location");
emit_untracked_cache_bypass_trace();
return Ok(());
}
let cache = build_untracked_cache(worktree_root, git_dir, format, &index, untracked_mode)?;
emit_untracked_cache_trace(old_cache.as_ref(), &cache);
index.set_untracked_cache(format, Some(&cache))?;
write_repository_index_ref(git_dir, format, &index)?;
Ok(())
}
fn repository_index_has_extension(
git_dir: &Path,
format: ObjectFormat,
signature: &[u8; 4],
) -> Result<bool> {
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(false);
}
let bytes = read_borrowed_index_bytes(&index_path)?;
sley_index::Index::bytes_have_extension(bytes.as_ref(), format, signature)
}
pub fn emit_untracked_cache_bypass_trace() {
sley_core::trace2::perf_read_directory_data("path", "");
}
fn index_extensions_without_cache_tree(extensions: &[u8]) -> Vec<u8> {
let mut offset = 0;
let mut filtered = Vec::new();
while offset < extensions.len() {
if extensions.len().saturating_sub(offset) < 8 {
return Vec::new();
}
let signature = &extensions[offset..offset + 4];
let size = u32::from_be_bytes([
extensions[offset + 4],
extensions[offset + 5],
extensions[offset + 6],
extensions[offset + 7],
]) as usize;
let end = offset + 8 + size;
if end > extensions.len() {
return Vec::new();
}
if signature != b"TREE" {
filtered.extend_from_slice(&extensions[offset..end]);
}
offset = end;
}
filtered
}
#[derive(Clone)]
struct ResolveUndoRecord {
path: Vec<u8>,
stages: [Option<(u32, ObjectId)>; 3],
}
fn record_resolve_undo_for_path(
index: &mut Index,
format: ObjectFormat,
path: &[u8],
entries: &[IndexEntry],
) -> Result<()> {
let mut stages = [None, None, None];
for entry in entries {
match entry.stage() {
Stage::Base => stages[0] = Some((entry.mode, entry.oid)),
Stage::Ours => stages[1] = Some((entry.mode, entry.oid)),
Stage::Theirs => stages[2] = Some((entry.mode, entry.oid)),
Stage::Normal => {}
}
}
if stages.iter().all(Option::is_none) {
return Ok(());
}
let mut records = parse_resolve_undo_records(index.extension(b"REUC")?, format)?;
records.retain(|record| record.path.as_slice() != path);
records.push(ResolveUndoRecord {
path: path.to_vec(),
stages,
});
records.sort_by(|left, right| left.path.cmp(&right.path));
set_resolve_undo_extension(index, &records)
}
fn record_resolve_undo_for_range(
index: &mut Index,
format: ObjectFormat,
path: &[u8],
range: Range<usize>,
) -> Result<()> {
if range.is_empty() {
return Ok(());
}
let entries = index.entries[range].to_vec();
record_resolve_undo_for_path(index, format, path, &entries)
}
fn parse_resolve_undo_records(
body: Option<&[u8]>,
format: ObjectFormat,
) -> Result<Vec<ResolveUndoRecord>> {
let Some(body) = body else {
return Ok(Vec::new());
};
let mut records = Vec::new();
let mut offset = 0usize;
while offset < body.len() {
let path_end = body[offset..]
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| GitError::InvalidFormat("truncated REUC path".into()))?
+ offset;
let path = body[offset..path_end].to_vec();
offset = path_end + 1;
let mut modes = [0u32; 3];
for mode in &mut modes {
let mode_end = body[offset..]
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| GitError::InvalidFormat("truncated REUC mode".into()))?
+ offset;
let text = std::str::from_utf8(&body[offset..mode_end])
.map_err(|_| GitError::InvalidFormat("invalid REUC mode".into()))?;
*mode = u32::from_str_radix(text, 8)
.map_err(|_| GitError::InvalidFormat("invalid REUC mode".into()))?;
offset = mode_end + 1;
}
let mut stages = [None, None, None];
for (idx, mode) in modes.into_iter().enumerate() {
if mode == 0 {
continue;
}
let end = offset
.checked_add(format.raw_len())
.ok_or_else(|| GitError::InvalidFormat("REUC oid length overflow".into()))?;
if end > body.len() {
return Err(GitError::InvalidFormat("truncated REUC oid".into()));
}
stages[idx] = Some((mode, ObjectId::from_raw(format, &body[offset..end])?));
offset = end;
}
records.push(ResolveUndoRecord { path, stages });
}
Ok(records)
}
fn set_resolve_undo_extension(index: &mut Index, records: &[ResolveUndoRecord]) -> Result<()> {
let mut body = Vec::new();
for record in records {
body.extend_from_slice(&record.path);
body.push(0);
for stage in record.stages {
match stage {
Some((mode, _)) => body.extend_from_slice(format!("{mode:o}").as_bytes()),
None => body.push(b'0'),
}
body.push(0);
}
for (_, oid) in record.stages.into_iter().flatten() {
body.extend_from_slice(oid.as_bytes());
}
}
let chunks = index.extension_chunks()?;
let mut rebuilt = Vec::with_capacity(index.extensions.len() + body.len() + 8);
let mut replaced = false;
for (signature, chunk_body) in chunks {
if &signature == b"REUC" {
if !body.is_empty() {
append_index_extension(&mut rebuilt, b"REUC", &body)?;
}
replaced = true;
} else {
append_index_extension(&mut rebuilt, &signature, chunk_body)?;
}
}
if !replaced && !body.is_empty() {
append_index_extension(&mut rebuilt, b"REUC", &body)?;
}
index.extensions = rebuilt;
Ok(())
}
pub fn clear_resolve_undo(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<()> {
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
match fs::read(&index_path) {
Ok(bytes) => {
let mut index = Index::parse(&bytes, format)?;
set_resolve_undo_extension(&mut index, &[])?;
write_repository_index_ref(git_dir, format, &index)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
fn append_index_extension(out: &mut Vec<u8>, signature: &[u8; 4], body: &[u8]) -> Result<()> {
let len = u32::try_from(body.len())
.map_err(|_| GitError::InvalidFormat("index extension body too large".into()))?;
out.extend_from_slice(signature);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(body);
Ok(())
}
fn index_extensions_without_split_index_link(extensions: &[u8]) -> Vec<u8> {
let mut offset = 0;
let mut filtered = Vec::new();
while offset < extensions.len() {
if extensions.len().saturating_sub(offset) < 8 {
filtered.extend_from_slice(&extensions[offset..]);
break;
}
let signature = &extensions[offset..offset + 4];
let len = u32::from_be_bytes([
extensions[offset + 4],
extensions[offset + 5],
extensions[offset + 6],
extensions[offset + 7],
]) as usize;
let end = offset.saturating_add(8).saturating_add(len);
if end > extensions.len() {
filtered.extend_from_slice(&extensions[offset..]);
break;
}
if signature != b"link" {
filtered.extend_from_slice(&extensions[offset..end]);
}
offset = end;
}
filtered
}
fn preserved_index_extensions(git_dir: &Path, format: ObjectFormat) -> Result<Vec<u8>> {
let index_path = repository_index_path(git_dir);
match fs::read(&index_path) {
Ok(bytes) => {
let index = Index::parse(&bytes, format)?;
Ok(index_extensions_without_cache_tree_or_resolve_undo(
&index.extensions,
))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(err.into()),
}
}
fn index_extensions_without_cache_tree_or_resolve_undo(extensions: &[u8]) -> Vec<u8> {
let mut filtered = Vec::new();
let mut offset = 0usize;
while offset + 8 <= extensions.len() {
let signature = &extensions[offset..offset + 4];
let len = u32::from_be_bytes([
extensions[offset + 4],
extensions[offset + 5],
extensions[offset + 6],
extensions[offset + 7],
]) as usize;
let end = offset + 8 + len;
if end > extensions.len() {
filtered.extend_from_slice(&extensions[offset..]);
break;
}
if signature != b"TREE" && signature != b"REUC" {
filtered.extend_from_slice(&extensions[offset..end]);
}
offset = end;
}
filtered
}
fn repository_index_is_split(git_dir: &Path, format: ObjectFormat) -> Result<bool> {
let index_path = repository_index_path(git_dir);
match fs::read(index_path) {
Ok(bytes) => Ok(Index::parse(&bytes, format)?
.split_index_link(format)?
.is_some()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err.into()),
}
}
fn git_test_split_index_enabled() -> bool {
env::var("GIT_TEST_SPLIT_INDEX")
.ok()
.is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "False" | "FALSE"))
}
pub fn write_repository_index(git_dir: &Path, format: ObjectFormat, index: Index) -> Result<()> {
let split = index.split_index_link(format)?.is_some()
|| repository_index_is_split(git_dir, format)?
|| git_test_split_index_enabled();
write_repository_index_ref_with_split(git_dir, format, &index, split)
}
pub fn write_repository_index_ref(
git_dir: &Path,
format: ObjectFormat,
index: &Index,
) -> Result<()> {
let split = index.split_index_link(format)?.is_some()
|| repository_index_is_split(git_dir, format)?
|| git_test_split_index_enabled();
write_repository_index_ref_with_split(git_dir, format, index, split)
}
fn write_repository_index_ref_with_split(
git_dir: &Path,
format: ObjectFormat,
index: &Index,
split: bool,
) -> Result<()> {
let index_path = repository_index_path(git_dir);
if !split || alternate_index_output_path(git_dir, &index_path) {
let smudged_entries = racily_clean_entry_indexes_before_write(git_dir, format, index)?;
let extensions = if index.split_index_link(format)?.is_some() {
Cow::Owned(index_extensions_without_split_index_link(&index.extensions))
} else {
Cow::Borrowed(index.extensions.as_slice())
};
let bytes = if smudged_entries.is_empty() && matches!(extensions, Cow::Borrowed(_)) {
index.write(format)?
} else {
write_index_with_entry_size_overrides(format, index, &smudged_entries, &extensions)?
};
fs::write(&index_path, bytes)?;
apply_index_shared_file_mode(git_dir, &index_path, None)?;
return Ok(());
}
if let Some(link) = index.split_index_link(format)?
&& !link.base_oid.is_null()
&& let Some(base) = read_shared_index_for_link(git_dir, &index_path, format, &link)?
&& !split_index_delta_exceeds_threshold(git_dir, index, &base)
{
let (entries, link) = split_index_delta_entries(index, &base, &link)?;
let extensions =
index_extensions_without_split_index_link(&index_extensions_without_cache_tree(
&index.extensions,
));
let mut primary = Index {
version: index.version,
entries,
extensions,
checksum: None,
};
primary.set_split_index_link(Some(&link))?;
fs::write(&index_path, primary.write(format)?)?;
apply_index_shared_file_mode(git_dir, &index_path, None)?;
return Ok(());
}
let mode_source = fs::metadata(&index_path)
.ok()
.map(|metadata| metadata.permissions());
let mut shared = index.clone();
smudge_racily_clean_entries_before_write(git_dir, format, &mut shared)?;
shared.clear_split_index_link()?;
shared.extensions = index_extensions_without_cache_tree(&shared.extensions);
let shared_bytes = shared.write(format)?;
let shared_oid = index_checksum_from_bytes(format, &shared_bytes)?;
let shared_path = git_dir.join(format!("sharedindex.{shared_oid}"));
if !shared_path.exists() {
fs::write(&shared_path, &shared_bytes)?;
}
apply_index_shared_file_mode(git_dir, &shared_path, mode_source.as_ref())?;
clean_shared_index_files(git_dir, shared_oid)?;
let mut primary = Index {
version: index.version,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
};
primary.set_split_index_link(Some(&SplitIndexLink::new(shared_oid)))?;
fs::write(&index_path, primary.write(format)?)?;
apply_index_shared_file_mode(git_dir, &index_path, mode_source.as_ref())?;
Ok(())
}
fn alternate_index_output_path(git_dir: &Path, index_path: &Path) -> bool {
env::var_os("GIT_INDEX_FILE").is_some() && index_path != git_dir.join("index")
}
fn clean_shared_index_files(git_dir: &Path, current_oid: ObjectId) -> Result<()> {
let Some(expire_before) = shared_index_expire_before(git_dir) else {
return Ok(());
};
let current_name = format!("sharedindex.{current_oid}");
let mut expired = Vec::new();
for entry in fs::read_dir(git_dir)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !name.starts_with("sharedindex.") || name == current_name {
continue;
}
let metadata = entry.metadata()?;
let Ok(modified) = metadata.modified() else {
continue;
};
if modified <= expire_before {
expired.push((modified, entry.path()));
}
}
expired.sort_by_key(|(modified, _)| *modified);
let delete_count = expired.len().saturating_sub(1);
for (_, path) in expired.into_iter().take(delete_count) {
let _ = fs::remove_file(path);
}
Ok(())
}
fn shared_index_expire_before(git_dir: &Path) -> Option<SystemTime> {
let value = sley_config::read_repo_config(git_dir, None)
.ok()
.and_then(|config| {
config
.get("splitIndex", None, "sharedIndexExpire")
.map(str::to_string)
})
.unwrap_or_else(|| "2.weeks.ago".to_string());
let value = value.trim();
if value.eq_ignore_ascii_case("never") {
return None;
}
if value.eq_ignore_ascii_case("now") {
return Some(SystemTime::now());
}
if let Some(days) = value
.strip_suffix(".days.ago")
.or_else(|| value.strip_suffix(".day.ago"))
.and_then(|days| days.parse::<u64>().ok())
{
return SystemTime::now().checked_sub(Duration::from_secs(days * 24 * 60 * 60));
}
if let Some(weeks) = value
.strip_suffix(".weeks.ago")
.or_else(|| value.strip_suffix(".week.ago"))
.and_then(|weeks| weeks.parse::<u64>().ok())
{
return SystemTime::now().checked_sub(Duration::from_secs(weeks * 7 * 24 * 60 * 60));
}
SystemTime::now().checked_sub(Duration::from_secs(14 * 24 * 60 * 60))
}
fn apply_index_shared_file_mode(
git_dir: &Path,
path: &Path,
mode_source: Option<&fs::Permissions>,
) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let current = fs::metadata(path)?.permissions();
let source_mode = mode_source
.map(fs::Permissions::mode)
.unwrap_or_else(|| current.mode());
let mode = sley_config::read_repo_config(git_dir, None)
.ok()
.and_then(|config| {
config
.get("core", None, "sharedRepository")
.and_then(|value| shared_repository_file_mode(value, source_mode))
})
.unwrap_or(source_mode & 0o7777);
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
}
#[cfg(not(unix))]
{
let _ = git_dir;
let _ = path;
let _ = mode_source;
}
Ok(())
}
#[cfg(unix)]
fn shared_repository_file_mode(value: &str, source_mode: u32) -> Option<u32> {
match value {
"umask" | "false" | "no" | "off" | "0" => None,
"group" | "true" | "yes" | "on" | "1" => Some((source_mode | 0o660) & 0o7777),
"all" | "world" | "everybody" | "2" | "3" => Some((source_mode | 0o664) & 0o7777),
value => {
let parsed = u32::from_str_radix(value, 8).ok()?;
(parsed & 0o600 == 0o600).then_some(parsed & 0o666)
}
}
}
fn read_shared_index_for_link(
git_dir: &Path,
index_path: &Path,
format: ObjectFormat,
link: &SplitIndexLink,
) -> Result<Option<Index>> {
let name = format!("sharedindex.{}", link.base_oid);
let bytes = match fs::read(git_dir.join(&name)) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let alternate = index_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(&name);
match fs::read(alternate) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
}
}
Err(err) => return Err(err.into()),
};
let base = Index::parse(&bytes, format)?;
if base.checksum != Some(link.base_oid) {
return Ok(None);
}
Ok(Some(base))
}
fn split_index_delta_exceeds_threshold(git_dir: &Path, index: &Index, base: &Index) -> bool {
let max_percent = sley_config::read_repo_config(git_dir, None)
.ok()
.and_then(|config| {
config
.get("splitIndex", None, "maxPercentChange")
.and_then(|value| value.parse::<i64>().ok())
})
.unwrap_or(20);
match max_percent {
0 => return true,
100.. => return false,
value if value < 0 => {}
_ => {}
}
let not_shared = count_entries_not_shared_with_base(index, base);
(index.entries.len() as i64) * max_percent < (not_shared as i64) * 100
}
fn count_entries_not_shared_with_base(index: &Index, base: &Index) -> usize {
index
.entries
.iter()
.filter(|entry| {
base.entries
.binary_search_by(|base_entry| compare_index_key(base_entry, entry))
.is_err()
})
.count()
}
fn split_index_delta_entries(
index: &Index,
base: &Index,
previous_link: &SplitIndexLink,
) -> Result<(Vec<IndexEntry>, SplitIndexLink)> {
let mut delete_positions = Vec::new();
let mut replace_positions = Vec::new();
let mut replacements = Vec::new();
let mut additions = Vec::new();
let mut base_pos = 0usize;
let mut index_pos = 0usize;
while base_pos < base.entries.len() && index_pos < index.entries.len() {
match compare_index_key(&base.entries[base_pos], &index.entries[index_pos]) {
Ordering::Equal => {
if previous_link
.delete_positions
.binary_search(&(base_pos as u32))
.is_ok()
{
delete_positions.push(base_pos as u32);
additions.push(index.entries[index_pos].clone());
} else if !index_entry_content_eq(&base.entries[base_pos], &index.entries[index_pos])
{
replace_positions.push(base_pos as u32);
let mut replacement = index.entries[index_pos].clone();
replacement.path = BString::from(Vec::<u8>::new());
replacement.refresh_name_length();
replacements.push(replacement);
}
base_pos += 1;
index_pos += 1;
}
Ordering::Less => {
delete_positions.push(base_pos as u32);
base_pos += 1;
}
Ordering::Greater => {
additions.push(index.entries[index_pos].clone());
index_pos += 1;
}
}
}
while base_pos < base.entries.len() {
delete_positions.push(base_pos as u32);
base_pos += 1;
}
while index_pos < index.entries.len() {
additions.push(index.entries[index_pos].clone());
index_pos += 1;
}
replacements.extend(additions);
Ok((
replacements,
SplitIndexLink {
base_oid: previous_link.base_oid,
delete_positions,
replace_positions,
},
))
}
fn compare_index_key(left: &IndexEntry, right: &IndexEntry) -> Ordering {
left.path
.as_bytes()
.cmp(right.path.as_bytes())
.then_with(|| left.stage().as_u16().cmp(&right.stage().as_u16()))
}
fn index_entry_content_eq(left: &IndexEntry, right: &IndexEntry) -> bool {
const ONDISK_FLAGS: u16 = sley_index::INDEX_FLAG_STAGE_MASK
| sley_index::INDEX_FLAG_VALID
| sley_index::INDEX_FLAG_EXTENDED;
left.ctime_seconds == right.ctime_seconds
&& left.ctime_nanoseconds == right.ctime_nanoseconds
&& left.mtime_seconds == right.mtime_seconds
&& left.mtime_nanoseconds == right.mtime_nanoseconds
&& left.dev == right.dev
&& left.ino == right.ino
&& left.mode == right.mode
&& left.uid == right.uid
&& left.gid == right.gid
&& left.size == right.size
&& left.oid == right.oid
&& (left.flags & ONDISK_FLAGS) == (right.flags & ONDISK_FLAGS)
&& left.flags_extended == right.flags_extended
}
fn write_index_with_entry_size_overrides(
format: ObjectFormat,
index: &Index,
zero_size_entries: &[usize],
extensions: &[u8],
) -> Result<Vec<u8>> {
if !(2..=4).contains(&index.version) {
return Err(GitError::Unsupported(
"canonical writer currently emits index v2/v3/v4".into(),
));
}
let mut out = Vec::new();
out.extend_from_slice(b"DIRC");
out.extend_from_slice(&index.version.to_be_bytes());
out.extend_from_slice(&(index.entries.len() as u32).to_be_bytes());
let mut previous_path = Vec::new();
for (position, entry) in index.entries.iter().enumerate() {
let start = out.len();
out.extend_from_slice(&entry.ctime_seconds.to_be_bytes());
out.extend_from_slice(&entry.ctime_nanoseconds.to_be_bytes());
out.extend_from_slice(&entry.mtime_seconds.to_be_bytes());
out.extend_from_slice(&entry.mtime_nanoseconds.to_be_bytes());
out.extend_from_slice(&entry.dev.to_be_bytes());
out.extend_from_slice(&entry.ino.to_be_bytes());
out.extend_from_slice(&entry.mode.to_be_bytes());
out.extend_from_slice(&entry.uid.to_be_bytes());
out.extend_from_slice(&entry.gid.to_be_bytes());
let size = if zero_size_entries.binary_search(&position).is_ok() {
0
} else {
entry.size
};
out.extend_from_slice(&size.to_be_bytes());
if entry.oid.format() != format {
return Err(GitError::Unsupported(format!(
"index writer expects {} ids",
format.name()
)));
}
out.extend_from_slice(entry.oid.as_bytes());
let has_extended_flags =
entry.flags & INDEX_FLAG_EXTENDED != 0 || entry.flags_extended != 0;
if has_extended_flags && index.version < 3 {
return Err(GitError::Unsupported(
"index extended flags require version 3".into(),
));
}
let flags = if has_extended_flags {
entry.flags | INDEX_FLAG_EXTENDED
} else {
entry.flags & !INDEX_FLAG_EXTENDED
};
out.extend_from_slice(&flags.to_be_bytes());
if has_extended_flags {
out.extend_from_slice(&entry.flags_extended.to_be_bytes());
}
if index.version == 4 {
let common_prefix_len = common_prefix_len(&previous_path, entry.path.as_bytes());
let strip_len = previous_path.len() - common_prefix_len;
encode_index_v4_path_strip_len(strip_len, &mut out);
out.extend_from_slice(&entry.path.as_bytes()[common_prefix_len..]);
out.push(0);
previous_path = entry.path.as_bytes().to_vec();
} else {
out.extend_from_slice(entry.path.as_bytes());
out.push(0);
while (out.len() - start) % 8 != 0 {
out.push(0);
}
}
}
out.extend_from_slice(extensions);
let checksum = sley_core::digest_bytes(format, &out)?;
out.extend_from_slice(checksum.as_bytes());
Ok(out)
}
fn encode_index_v4_path_strip_len(strip_len: usize, out: &mut Vec<u8>) {
let mut bytes = Vec::new();
bytes.push((strip_len & 0x7f) as u8);
let mut value = strip_len >> 7;
while value != 0 {
value -= 1;
bytes.push(((value & 0x7f) as u8) | 0x80);
value >>= 7;
}
for byte in bytes.iter().rev() {
out.push(*byte);
}
}
fn common_prefix_len(left: &[u8], right: &[u8]) -> usize {
left.iter()
.zip(right.iter())
.take_while(|(left, right)| left == right)
.count()
}
fn index_checksum_from_bytes(format: ObjectFormat, bytes: &[u8]) -> Result<ObjectId> {
let hash_len = format.raw_len();
if bytes.len() < hash_len {
return Err(GitError::InvalidFormat(
"index too short for checksum".into(),
));
}
ObjectId::from_raw(format, &bytes[bytes.len() - hash_len..])
}
pub fn enable_split_index(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let mut index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
normalize_index_version_for_extended_flags(&mut index);
write_repository_index_ref_with_split(git_dir, format, &index, true)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
pub fn disable_split_index(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
if !repository_index_path(git_dir).exists() {
return Ok(UpdateIndexResult {
entries: 0,
updated: Vec::new(),
});
}
let mut index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
normalize_index_version_for_extended_flags(&mut index);
write_repository_index_ref_with_split(git_dir, format, &index, false)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated: Vec::new(),
})
}
fn smudge_racily_clean_entries_before_write(
git_dir: &Path,
format: ObjectFormat,
index: &mut Index,
) -> Result<()> {
for position in racily_clean_entry_indexes_before_write(git_dir, format, index)? {
index.entries[position].size = 0;
}
Ok(())
}
fn racily_clean_entry_indexes_before_write(
git_dir: &Path,
format: ObjectFormat,
index: &Index,
) -> Result<Vec<usize>> {
let index_path = repository_index_path(git_dir);
let Some(index_mtime) = fs::metadata(&index_path)
.ok()
.and_then(|metadata| sley_index::file_mtime_parts(&metadata))
else {
return Ok(Vec::new());
};
if index_mtime == (0, 0) {
return Ok(Vec::new());
}
let Some(worktree_root) = (match worktree_root_for_git_dir(git_dir) {
Ok(worktree_root) => worktree_root,
Err(_) => return Ok(Vec::new()),
}) else {
return Ok(Vec::new());
};
let mut smudged = Vec::new();
for (position, entry) in index.entries.iter().enumerate() {
if index_entry_stage(entry) != 0 || sley_index::is_gitlink(entry.mode) {
continue;
}
let entry_mtime = (
u64::from(entry.mtime_seconds),
u64::from(entry.mtime_nanoseconds),
);
if entry_mtime == (0, 0) || index_mtime > entry_mtime {
continue;
}
let absolute = worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?);
let Ok(metadata) = fs::symlink_metadata(&absolute) else {
continue;
};
if entry.mode != worktree_entry_mode(&metadata)
|| !worktree_entry_is_uptodate(entry, &metadata)
{
continue;
}
let body = if metadata.file_type().is_symlink() {
symlink_target_bytes(&absolute)?
} else if metadata.is_file() {
fs::read(&absolute)?
} else {
continue;
};
let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
if oid != entry.oid {
smudged.push(position);
}
}
Ok(smudged)
}
fn invalidate_untracked_cache_for_git_paths(
index: &mut Index,
format: ObjectFormat,
paths: &[Vec<u8>],
) -> Result<()> {
if paths.is_empty() {
return Ok(());
}
let Some(mut cache) = index.untracked_cache(format)? else {
return Ok(());
};
let Some(root) = cache.root.as_mut() else {
return Ok(());
};
for path in paths {
invalidate_untracked_cache_dir_for_path(root, path);
}
index.set_untracked_cache(format, Some(&cache))
}
fn invalidate_untracked_cache_dir_for_path(root: &mut UntrackedCacheDir, path: &[u8]) {
invalidate_untracked_cache_node(root);
let mut current = root;
let mut components = path.split(|byte| *byte == b'/').peekable();
while let Some(component) = components.next() {
if component.is_empty() || components.peek().is_none() {
break;
}
let Some(child) = current.dirs.iter_mut().find(|dir| dir.name == component) else {
break;
};
invalidate_untracked_cache_node(child);
current = child;
}
}
fn invalidate_untracked_cache_node(node: &mut UntrackedCacheDir) {
node.valid = false;
node.untracked.clear();
}
pub fn update_index_cacheinfo(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
entries: &[CacheInfoEntry],
add: bool,
verbose: bool,
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let mut updated = Vec::new();
let mut reports: Vec<String> = Vec::new();
let mut untracked_cache_invalidation_paths = Vec::new();
for cacheinfo in entries {
if !add
&& !index
.entries
.iter()
.any(|existing| existing.path == cacheinfo.path)
{
let path = String::from_utf8_lossy(&cacheinfo.path);
eprintln!("error: {path}: cannot add to the index - missing --add option?");
eprintln!("fatal: git update-index: --cacheinfo cannot add {path}");
return Err(GitError::Exit(128));
}
let flags = index_flags(cacheinfo.path.len(), cacheinfo.stage);
let entry = IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode: cacheinfo.mode,
uid: 0,
gid: 0,
size: 0,
oid: cacheinfo.oid,
flags,
flags_extended: 0,
path: BString::from(cacheinfo.path.as_slice()),
};
index.entries.retain(|existing| {
existing.path != cacheinfo.path || index_entry_stage(existing) != cacheinfo.stage
});
index.entries.push(entry);
untracked_cache_invalidation_paths.push(cacheinfo.path.clone());
updated.push(cacheinfo.oid);
reports.push(format!(
"add '{}'",
String::from_utf8_lossy(&cacheinfo.path)
));
}
index
.entries
.sort_by(|left, right| left.path.cmp(&right.path));
let null_entry = index.entries.iter().find(|entry| entry.oid.is_null());
if let Some(entry) = null_entry {
if verbose {
flush_update_index_reports(&reports)?;
}
eprintln!(
"error: cache entry has null sha1: {}",
String::from_utf8_lossy(&entry.path)
);
return Err(GitError::Exit(128));
}
invalidate_untracked_cache_for_git_paths(
&mut index,
format,
&untracked_cache_invalidation_paths,
)?;
write_repository_index_ref(git_dir, format, &index)?;
if verbose {
flush_update_index_reports(&reports)?;
}
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated,
})
}
fn flush_update_index_reports(reports: &[String]) -> Result<()> {
let mut stdout = std::io::stdout().lock();
for line in reports {
writeln!(stdout, "{line}")?;
}
stdout.flush()?;
Ok(())
}
pub fn update_index_index_info(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
records: &[IndexInfoRecord],
) -> Result<UpdateIndexResult> {
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let mut updated = Vec::new();
let mut untracked_cache_invalidation_paths = Vec::new();
for record in records {
match record {
IndexInfoRecord::Remove { path } => {
index.entries.retain(|existing| existing.path != *path);
untracked_cache_invalidation_paths.push(path.clone());
}
IndexInfoRecord::Add(cacheinfo) => {
let flags = index_flags(cacheinfo.path.len(), cacheinfo.stage);
let entry = IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode: cacheinfo.mode,
uid: 0,
gid: 0,
size: 0,
oid: cacheinfo.oid,
flags,
flags_extended: 0,
path: BString::from(cacheinfo.path.as_slice()),
};
if cacheinfo.stage == 0 {
index
.entries
.retain(|existing| existing.path != cacheinfo.path);
} else {
index.entries.retain(|existing| {
existing.path != cacheinfo.path
|| index_entry_stage(existing) != cacheinfo.stage
});
}
index.entries.push(entry);
untracked_cache_invalidation_paths.push(cacheinfo.path.clone());
updated.push(cacheinfo.oid);
}
}
}
index.entries.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| index_entry_stage(left).cmp(&index_entry_stage(right)))
});
invalidate_untracked_cache_for_git_paths(
&mut index,
format,
&untracked_cache_invalidation_paths,
)?;
write_repository_index_ref(git_dir, format, &index)?;
Ok(UpdateIndexResult {
entries: index.entries.len(),
updated,
})
}
fn index_flags(path_len: usize, stage: u16) -> u16 {
((stage & 0x3) << 12) | ((path_len.min(0xfff) as u16) & 0x0fff)
}
const INDEX_FLAG_ASSUME_UNCHANGED: u16 = 0x8000;
const INDEX_FLAG_EXTENDED: u16 = 0x4000;
const INDEX_EXTENDED_FLAG_SKIP_WORKTREE: u16 = 0x4000;
fn normalize_index_version_for_extended_flags(index: &mut Index) {
let has_extended_flags = index
.entries
.iter()
.any(|entry| entry.flags & INDEX_FLAG_EXTENDED != 0 || entry.flags_extended != 0);
if has_extended_flags && index.version < 3 {
index.version = 3;
} else if !has_extended_flags && index.version == 3 {
index.version = 2;
}
}
fn index_entry_stage(entry: &IndexEntry) -> u16 {
(entry.flags >> 12) & 0x3
}
fn stage0_oid_in_range(entries: &[IndexEntry], range: std::ops::Range<usize>) -> Option<ObjectId> {
entries[range]
.iter()
.find(|entry| index_entry_stage(entry) == 0)
.map(|entry| entry.oid)
}
fn index_entry_skip_worktree(entry: &IndexEntry) -> bool {
entry.flags & INDEX_FLAG_EXTENDED != 0
&& entry.flags_extended & INDEX_EXTENDED_FLAG_SKIP_WORKTREE != 0
}
fn print_update_index_path_error(path: &[u8], message: &str) {
let path = String::from_utf8_lossy(path);
eprintln!("error: {path}: {message}");
eprintln!("fatal: Unable to process path {path}");
}
fn print_update_index_needs_update(path: &[u8]) {
let path = String::from_utf8_lossy(path);
println!("{path}: needs update");
}
pub fn write_tree_from_index(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<ObjectId> {
write_tree_from_index_with_options(git_dir, format, WriteTreeOptions::default())
}
pub fn write_tree_from_index_with_odb(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
odb: &FileObjectDatabase,
) -> Result<ObjectId> {
write_tree_from_index_with_options_and_odb(
git_dir.as_ref(),
format,
WriteTreeOptions::default(),
odb,
)
}
pub fn write_tree_from_index_with_options(
git_dir: impl AsRef<Path>,
format: ObjectFormat,
options: WriteTreeOptions,
) -> Result<ObjectId> {
let git_dir = git_dir.as_ref();
let odb = FileObjectDatabase::from_git_dir(git_dir, format);
write_tree_from_index_with_options_and_odb(git_dir, format, options, &odb)
}
fn write_tree_from_index_with_options_and_odb(
git_dir: &Path,
format: ObjectFormat,
options: WriteTreeOptions,
odb: &FileObjectDatabase,
) -> Result<ObjectId> {
let index_path = repository_index_path(git_dir);
let index_bytes = match fs::read(&index_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let mut checker = odb.presence_checker();
let empty: &[WriteTreeEntry<'_>] = &[];
return write_tree_entries_stream(
empty,
b"",
None,
odb,
&mut checker,
options.missing_ok,
);
}
Err(err) => return Err(err.into()),
};
let mut checker = odb.presence_checker();
if Index::bytes_have_extension(&index_bytes, format, b"link")? {
let index = sley_index::read_repository_index(git_dir, format)?;
return write_tree_from_owned_index(&index, format, &options, odb, &mut checker);
}
match BorrowedIndex::parse(&index_bytes, format) {
Ok(index) => write_tree_from_borrowed_index(&index, format, &options, odb, &mut checker),
Err(GitError::Unsupported(_)) => {
let index = Index::parse(&index_bytes, format)?;
write_tree_from_owned_index(&index, format, &options, odb, &mut checker)
}
Err(err) => Err(err),
}
}
fn write_tree_from_borrowed_index(
index: &BorrowedIndex<'_>,
format: ObjectFormat,
options: &WriteTreeOptions,
odb: &FileObjectDatabase,
checker: &mut ObjectPresenceChecker,
) -> Result<ObjectId> {
let cache_tree = if options.prefix.is_none() {
index.cache_tree(format).ok().flatten()
} else {
None
};
if options.prefix.is_none() && !index.entries.iter().any(|entry| entry.is_intent_to_add()) {
return write_tree_entries_stream(
&index.entries,
b"",
cache_tree.as_ref(),
odb,
checker,
options.missing_ok,
);
}
let entries = write_tree_entries_for_prefix(
index
.entries
.iter()
.filter(|entry| !entry.is_intent_to_add()),
options.prefix.as_deref(),
)?;
write_tree_entries_stream(
&entries,
b"",
cache_tree.as_ref(),
odb,
checker,
options.missing_ok,
)
}
fn write_tree_from_owned_index(
index: &Index,
format: ObjectFormat,
options: &WriteTreeOptions,
odb: &FileObjectDatabase,
checker: &mut ObjectPresenceChecker,
) -> Result<ObjectId> {
let cache_tree = if options.prefix.is_none() {
index.cache_tree(format).ok().flatten()
} else {
None
};
if options.prefix.is_none() && !index.entries.iter().any(|entry| entry.is_intent_to_add()) {
return write_tree_entries_stream(
&index.entries,
b"",
cache_tree.as_ref(),
odb,
checker,
options.missing_ok,
);
}
let entries = write_tree_entries_for_prefix(
index
.entries
.iter()
.filter(|entry| !entry.is_intent_to_add()),
options.prefix.as_deref(),
)?;
write_tree_entries_stream(
&entries,
b"",
cache_tree.as_ref(),
odb,
checker,
options.missing_ok,
)
}
#[derive(Clone, Copy)]
struct WriteTreeEntry<'a> {
path: &'a [u8],
mode: u32,
oid: ObjectId,
}
trait WriteTreeIndexEntry {
fn write_tree_path(&self) -> &[u8];
fn write_tree_mode(&self) -> u32;
fn write_tree_oid(&self) -> ObjectId;
}
impl WriteTreeIndexEntry for IndexEntry {
fn write_tree_path(&self) -> &[u8] {
self.path.as_bytes()
}
fn write_tree_mode(&self) -> u32 {
self.mode
}
fn write_tree_oid(&self) -> ObjectId {
self.oid
}
}
impl WriteTreeIndexEntry for IndexEntryRef<'_> {
fn write_tree_path(&self) -> &[u8] {
self.path
}
fn write_tree_mode(&self) -> u32 {
self.mode
}
fn write_tree_oid(&self) -> ObjectId {
self.oid
}
}
impl WriteTreeIndexEntry for WriteTreeEntry<'_> {
fn write_tree_path(&self) -> &[u8] {
self.path
}
fn write_tree_mode(&self) -> u32 {
self.mode
}
fn write_tree_oid(&self) -> ObjectId {
self.oid
}
}
fn write_tree_entries_for_prefix<'a, E>(
entries: impl IntoIterator<Item = &'a E>,
prefix: Option<&[u8]>,
) -> Result<Vec<WriteTreeEntry<'a>>>
where
E: WriteTreeIndexEntry + 'a,
{
let Some(prefix) = prefix else {
return Ok(entries
.into_iter()
.map(|entry| WriteTreeEntry {
path: entry.write_tree_path(),
mode: entry.write_tree_mode(),
oid: entry.write_tree_oid(),
})
.collect());
};
let trimmed_len = prefix
.iter()
.rposition(|byte| *byte != b'/')
.map(|idx| idx + 1)
.unwrap_or(0);
let trimmed = &prefix[..trimmed_len];
if trimmed.is_empty() {
return Ok(entries
.into_iter()
.map(|entry| WriteTreeEntry {
path: entry.write_tree_path(),
mode: entry.write_tree_mode(),
oid: entry.write_tree_oid(),
})
.collect());
}
let mut prefixed = Vec::new();
for entry in entries {
let Some(remainder) = entry.write_tree_path().strip_prefix(trimmed) else {
continue;
};
let Some(stripped) = remainder.strip_prefix(b"/") else {
continue;
};
if stripped.is_empty() {
continue;
}
prefixed.push(WriteTreeEntry {
path: stripped,
mode: entry.write_tree_mode(),
oid: entry.write_tree_oid(),
});
}
if prefixed.is_empty() {
eprintln!(
"fatal: git-write-tree: prefix {} not found",
String::from_utf8_lossy(prefix)
);
return Err(GitError::Exit(128));
}
Ok(prefixed)
}
fn write_tree_entries_stream<E>(
entries: &[E],
prefix: &[u8],
cache_tree: Option<&CacheTree>,
odb: &FileObjectDatabase,
checker: &mut ObjectPresenceChecker,
missing_ok: bool,
) -> Result<ObjectId>
where
E: WriteTreeIndexEntry,
{
if let Some(oid) = valid_cache_tree_oid(cache_tree, entries.len()) {
return Ok(oid);
}
let mut tree_entries = Vec::new();
let mut index = 0usize;
while index < entries.len() {
let entry = &entries[index];
let path = entry.write_tree_path();
let Some(remainder) = path.strip_prefix(prefix) else {
return Err(GitError::InvalidPath(format!(
"invalid index path {}",
String::from_utf8_lossy(path)
)));
};
if remainder.is_empty() || remainder[0] == b'/' {
return Err(GitError::InvalidPath(format!(
"invalid index path {}",
String::from_utf8_lossy(path)
)));
}
if entry.write_tree_mode() == SPARSE_DIR_MODE
&& let Some(name) = remainder.strip_suffix(b"/")
&& !name.is_empty()
&& !name.contains(&b'/')
{
let oid = entry.write_tree_oid();
if !missing_ok && !checker.contains(&oid)? {
eprintln!(
"error: invalid object {:o} {} for '{}'",
SPARSE_DIR_MODE,
oid,
String::from_utf8_lossy(path)
);
eprintln!("fatal: git-write-tree: error building trees");
return Err(GitError::Exit(128));
}
tree_entries.push(TreeEntry {
mode: SPARSE_DIR_MODE,
name: BString::from(name),
oid,
});
index += 1;
continue;
}
if let Some(slash) = remainder.iter().position(|byte| *byte == b'/') {
let name = &remainder[..slash];
if name.is_empty() {
return Err(GitError::InvalidPath(format!(
"invalid index path {}",
String::from_utf8_lossy(path)
)));
}
let start = index;
let child_cache = cache_tree.and_then(|tree| {
tree.subtrees
.iter()
.find(|child| child.name.as_slice() == name)
.map(|child| &child.tree)
});
if let Some(cached_count) = valid_cache_tree_entry_count(child_cache) {
let end = start.saturating_add(cached_count);
if cached_count > 0
&& end <= entries.len()
&& same_tree_component(entries[end - 1].write_tree_path(), prefix, name)?
&& (end == entries.len()
|| !same_tree_component(entries[end].write_tree_path(), prefix, name)?)
{
index = end;
} else {
index += 1;
while index < entries.len()
&& same_tree_component(entries[index].write_tree_path(), prefix, name)?
{
index += 1;
}
}
} else {
index += 1;
while index < entries.len()
&& same_tree_component(entries[index].write_tree_path(), prefix, name)?
{
index += 1;
}
}
if let Some(oid) = valid_cache_tree_oid(child_cache, index - start) {
tree_entries.push(TreeEntry {
mode: 0o040000,
name: BString::from(name),
oid,
});
continue;
}
let mut child_prefix = Vec::with_capacity(prefix.len() + name.len() + 1);
child_prefix.extend_from_slice(prefix);
child_prefix.extend_from_slice(name);
child_prefix.push(b'/');
let oid = write_tree_entries_stream(
&entries[start..index],
&child_prefix,
child_cache,
odb,
checker,
missing_ok,
)?;
tree_entries.push(TreeEntry {
mode: 0o040000,
name: BString::from(name),
oid,
});
continue;
}
let mode = entry.write_tree_mode();
let oid = entry.write_tree_oid();
if !missing_ok && !sley_index::is_gitlink(mode) && !checker.contains(&oid)? {
eprintln!(
"error: invalid object {:o} {} for '{}'",
mode,
oid,
String::from_utf8_lossy(path)
);
eprintln!("fatal: git-write-tree: error building trees");
return Err(GitError::Exit(128));
}
tree_entries.push(TreeEntry {
mode,
name: BString::from(remainder),
oid,
});
index += 1;
}
tree_entries.sort_by(|left, right| {
git_tree_entry_cmp(
left.name.as_bytes(),
left.mode,
right.name.as_bytes(),
right.mode,
)
});
odb.write_object(EncodedObject::new(
ObjectType::Tree,
Tree {
entries: tree_entries,
}
.write(),
))
}
fn valid_cache_tree_oid(tree: Option<&CacheTree>, entry_count: usize) -> Option<ObjectId> {
let tree = tree?;
if valid_cache_tree_entry_count(Some(tree))? != entry_count {
return None;
}
tree.oid
}
fn valid_cache_tree_entry_count(tree: Option<&CacheTree>) -> Option<usize> {
let tree = tree?;
if tree.entry_count < 0 || tree.oid.is_none() {
return None;
}
Some(tree.entry_count as usize)
}
fn same_tree_component(path: &[u8], prefix: &[u8], name: &[u8]) -> Result<bool> {
let Some(remainder) = path.strip_prefix(prefix) else {
return Err(GitError::InvalidPath(format!(
"invalid index path {}",
String::from_utf8_lossy(path)
)));
};
Ok(remainder.starts_with(name) && remainder.get(name.len()) == Some(&b'/'))
}
pub fn stream_short_status<F>(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
emit: F,
) -> Result<()>
where
F: for<'a> FnMut(ShortStatusRow<'a>) -> Result<StreamControl>,
{
stream_short_status_with_options(
worktree_root,
git_dir,
format,
ShortStatusOptions::default(),
emit,
)
}
pub fn short_status_count(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<usize> {
short_status_count_with_options(
worktree_root,
git_dir,
format,
ShortStatusOptions::default(),
)
}
pub fn short_status_count_with_options(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
options: ShortStatusOptions,
) -> Result<usize> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
if !options.include_ignored
&& let Some(count) = short_status_borrowed_head_matches_index_count_if_possible(
worktree_root,
git_dir,
format,
&db,
options.untracked_mode,
)?
{
return Ok(count);
}
let mut count = 0usize;
stream_short_status_with_options(worktree_root, git_dir, format, options, |_| {
count += 1;
Ok(StreamControl::Continue)
})?;
Ok(count)
}
#[derive(Debug, Clone, Default)]
struct StatusProfileCounters {
fast_path_borrowed: bool,
read_dir_calls: u64,
dir_entries_seen: u64,
file_type_calls: u64,
ignore_checks: u64,
ignore_pattern_tests: u64,
ignore_glob_fallback_tests: u64,
tracked_exact_hits: u64,
tracked_dir_prefix_hits: u64,
tracked_skip_worktree_prefix_hits: u64,
read_dir_entry_vec_cap_bytes: u64,
read_dir_entry_vec_max_len: u64,
read_dir_entry_vec_max_cap: u64,
read_dir_name_vec_cap_bytes: u64,
read_dir_name_vec_max_len: u64,
read_dir_name_vec_max_cap: u64,
untracked_rows: u64,
tracked_elapsed_us: u128,
untracked_elapsed_us: u128,
render_elapsed_us: u128,
overlap_enabled: bool,
}
const STATUS_BORROWED_OVERLAP_MIN_STAGE0: usize = 1024;
const STATUS_WORKER_STACK_SIZE: usize = 32 * 1024;
fn spawn_status_worker<'scope, 'env, F, T>(
scope: &'scope std::thread::Scope<'scope, 'env>,
name: &str,
f: F,
) -> Result<std::thread::ScopedJoinHandle<'scope, Result<T>>>
where
F: FnOnce() -> Result<T> + Send + 'scope,
T: Send + 'scope,
{
std::thread::Builder::new()
.name(name.to_string())
.stack_size(STATUS_WORKER_STACK_SIZE)
.spawn_scoped(scope, f)
.map_err(|err| GitError::Command(format!("failed to spawn status worker `{name}`: {err}")))
}
enum BorrowedIndexBytes {
Owned(Vec<u8>),
Mapped(sley_mmap::MappedFile),
}
impl AsRef<[u8]> for BorrowedIndexBytes {
fn as_ref(&self) -> &[u8] {
match self {
Self::Owned(bytes) => bytes,
Self::Mapped(bytes) => bytes.as_bytes(),
}
}
}
fn read_borrowed_index_bytes(index_path: &Path) -> Result<BorrowedIndexBytes> {
match sley_mmap::MappedFile::open_index(index_path) {
Ok(mapped) => Ok(BorrowedIndexBytes::Mapped(mapped)),
Err(_) => Ok(BorrowedIndexBytes::Owned(fs::read(index_path)?)),
}
}
impl StatusProfileCounters {
fn enabled() -> bool {
std::env::var_os("SLEY_STATUS_PROFILE").is_some_and(|value| value != "0")
}
fn memory_enabled() -> bool {
std::env::var_os("SLEY_STATUS_PROFILE")
.and_then(|value| value.into_string().ok())
.is_some_and(|value| value == "mem" || value == "memory")
}
fn merge_untracked(&mut self, other: StatusProfileCounters) {
self.read_dir_calls += other.read_dir_calls;
self.dir_entries_seen += other.dir_entries_seen;
self.file_type_calls += other.file_type_calls;
self.ignore_checks += other.ignore_checks;
self.ignore_pattern_tests += other.ignore_pattern_tests;
self.ignore_glob_fallback_tests += other.ignore_glob_fallback_tests;
self.tracked_exact_hits += other.tracked_exact_hits;
self.tracked_dir_prefix_hits += other.tracked_dir_prefix_hits;
self.tracked_skip_worktree_prefix_hits += other.tracked_skip_worktree_prefix_hits;
self.read_dir_entry_vec_cap_bytes += other.read_dir_entry_vec_cap_bytes;
self.read_dir_entry_vec_max_len = self
.read_dir_entry_vec_max_len
.max(other.read_dir_entry_vec_max_len);
self.read_dir_entry_vec_max_cap = self
.read_dir_entry_vec_max_cap
.max(other.read_dir_entry_vec_max_cap);
self.read_dir_name_vec_cap_bytes += other.read_dir_name_vec_cap_bytes;
self.read_dir_name_vec_max_len = self
.read_dir_name_vec_max_len
.max(other.read_dir_name_vec_max_len);
self.read_dir_name_vec_max_cap = self
.read_dir_name_vec_max_cap
.max(other.read_dir_name_vec_max_cap);
self.untracked_rows += other.untracked_rows;
self.untracked_elapsed_us += other.untracked_elapsed_us;
}
fn emit(&self) {
eprintln!(
"{{\"schema\":\"sley.status.profile.v1\",\
\"fast_path_borrowed\":{},\
\"read_dir_calls\":{},\
\"dir_entries_seen\":{},\
\"file_type_calls\":{},\
\"ignore_checks\":{},\
\"ignore_pattern_tests\":{},\
\"ignore_glob_fallback_tests\":{},\
\"tracked_exact_hits\":{},\
\"tracked_dir_prefix_hits\":{},\
\"tracked_skip_worktree_prefix_hits\":{},\
\"read_dir_entry_size\":{},\
\"read_dir_entry_vec_cap_bytes\":{},\
\"read_dir_entry_vec_max_len\":{},\
\"read_dir_entry_vec_max_cap\":{},\
\"read_dir_name_size\":{},\
\"read_dir_name_vec_cap_bytes\":{},\
\"read_dir_name_vec_max_len\":{},\
\"read_dir_name_vec_max_cap\":{},\
\"untracked_rows\":{},\
\"tracked_elapsed_us\":{},\
\"untracked_elapsed_us\":{},\
\"render_elapsed_us\":{},\
\"overlap_enabled\":{}}}",
self.fast_path_borrowed,
self.read_dir_calls,
self.dir_entries_seen,
self.file_type_calls,
self.ignore_checks,
self.ignore_pattern_tests,
self.ignore_glob_fallback_tests,
self.tracked_exact_hits,
self.tracked_dir_prefix_hits,
self.tracked_skip_worktree_prefix_hits,
std::mem::size_of::<fs::DirEntry>(),
self.read_dir_entry_vec_cap_bytes,
self.read_dir_entry_vec_max_len,
self.read_dir_entry_vec_max_cap,
std::mem::size_of::<std::ffi::OsString>(),
self.read_dir_name_vec_cap_bytes,
self.read_dir_name_vec_max_len,
self.read_dir_name_vec_max_cap,
self.untracked_rows,
self.tracked_elapsed_us,
self.untracked_elapsed_us,
self.render_elapsed_us,
self.overlap_enabled
);
}
}
fn status_profile_rss_vsz_bytes() -> Option<(u64, u64)> {
let pid = std::process::id().to_string();
let output = Command::new("ps")
.args(["-o", "rss=", "-o", "vsz=", "-p", &pid])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
let mut parts = text.split_whitespace();
let rss_kib = parts.next()?.parse::<u64>().ok()?;
let vsz_kib = parts.next()?.parse::<u64>().ok()?;
Some((rss_kib * 1024, vsz_kib * 1024))
}
fn status_profile_pause(label: &str) {
let Some(target) =
std::env::var_os("SLEY_STATUS_PROFILE_PAUSE_AT").and_then(|value| value.into_string().ok())
else {
return;
};
if target != label && target != "*" {
return;
}
let seconds = std::env::var("SLEY_STATUS_PROFILE_PAUSE_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(30);
eprintln!(
"{{\"schema\":\"sley.status.mem.pause.v1\",\"label\":\"{}\",\"pid\":{},\"seconds\":{}}}",
label,
std::process::id(),
seconds
);
std::thread::sleep(std::time::Duration::from_secs(seconds));
}
fn status_profile_mem(label: &str, details: &[(&str, usize)]) {
if !StatusProfileCounters::memory_enabled() {
return;
}
let (rss_bytes, vsz_bytes) = status_profile_rss_vsz_bytes().unwrap_or((0, 0));
eprint!(
"{{\"schema\":\"sley.status.mem.v1\",\"label\":\"{}\",\"pid\":{},\"rss_bytes\":{},\"vsz_bytes\":{}",
label,
std::process::id(),
rss_bytes,
vsz_bytes
);
for (key, value) in details {
eprint!(",\"{}\":{}", key, value);
}
eprintln!("}}");
status_profile_pause(label);
}
pub fn worktree_entry_state(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
path: impl AsRef<Path>,
expected_oid: &ObjectId,
expected_mode: u32,
index_probe: Option<&IndexStatProbe>,
) -> Result<WorktreeEntryState> {
let path = path.as_ref();
if path.is_absolute() {
return Err(GitError::InvalidPath(format!(
"worktree entry path {} is absolute",
path.display()
)));
}
let git_path = git_path_bytes(path)?;
worktree_entry_state_by_git_path(
worktree_root,
git_dir,
format,
&git_path,
expected_oid,
expected_mode,
index_probe,
)
}
pub fn worktree_entry_state_by_git_path(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
git_path: &[u8],
expected_oid: &ObjectId,
expected_mode: u32,
index_probe: Option<&IndexStatProbe>,
) -> Result<WorktreeEntryState> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let stat_cache =
index_probe.and_then(|probe| probe.stat_cache_for(git_path, expected_oid, expected_mode));
let Some(worktree_entry) = worktree_entry_for_git_path(
worktree_root,
git_dir,
format,
git_path,
expected_oid,
expected_mode,
stat_cache.as_ref(),
)?
else {
return Ok(WorktreeEntryState::Deleted);
};
if worktree_entry.mode == expected_mode && worktree_entry.oid == *expected_oid {
Ok(WorktreeEntryState::Clean)
} else {
Ok(WorktreeEntryState::Modified)
}
}
pub fn stream_short_status_with_options<F>(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
options: ShortStatusOptions,
mut emit: F,
) -> Result<()>
where
F: for<'a> FnMut(ShortStatusRow<'a>) -> Result<StreamControl>,
{
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
if !options.include_ignored
&& let Some(()) = stream_short_status_borrowed_head_matches_index_if_possible(
worktree_root,
git_dir,
format,
&db,
options.untracked_mode,
&mut emit,
)?
{
return Ok(());
}
for entry in collect_short_status_with_options(worktree_root, git_dir, format, options)? {
if emit(entry.as_row())?.is_stop() {
break;
}
}
Ok(())
}
fn collect_short_status_with_options(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
options: ShortStatusOptions,
) -> Result<Vec<ShortStatusEntry>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
if !options.include_ignored
&& let Some(entries) = short_status_borrowed_head_matches_index_if_possible(
worktree_root,
git_dir,
format,
&db,
options.untracked_mode,
)?
{
return Ok(entries);
}
let (mut parsed_index, mut stat_cache, mut head_matches_index) =
read_index_with_stat_cache(git_dir, format, &db)?;
let sparse_checkout_active = sparse_checkout_active_for_status(git_dir, &parsed_index);
if sparse_checkout_active && parsed_index.entries.iter().any(IndexEntry::is_sparse_dir) {
expand_sparse_index(&mut parsed_index, &db, format)?;
stat_cache = IndexStatCache::from_index_mtime(&parsed_index, stat_cache.index_mtime);
head_matches_index = false;
}
let mut unmerged_entries = short_status_unmerged_entries(&parsed_index);
let unmerged_paths = unmerged_entries
.iter()
.map(|entry| entry.path.clone())
.collect::<BTreeSet<_>>();
if head_matches_index && !options.include_ignored {
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let entries = short_status_tracked_only(
worktree_root,
git_dir,
format,
&db,
&parsed_index,
&stat_cache,
true,
sparse_checkout_active,
options.untracked_mode,
);
let mut entries = entries?;
entries.retain(|entry| !unmerged_paths.contains(&entry.path));
let untracked_paths = status_untracked_paths_from_index(
worktree_root,
git_dir,
&parsed_index,
&stat_cache,
&mut ignores,
options.untracked_mode,
None,
)?;
for path in untracked_paths {
entries.push(ShortStatusEntry {
index: b'?',
worktree: b'?',
path,
head_mode: None,
index_mode: None,
worktree_mode: None,
head_oid: None,
index_oid: None,
submodule: None,
});
}
entries.append(&mut unmerged_entries);
entries.sort_by(|left, right| {
status_sort_category(left)
.cmp(&status_sort_category(right))
.then_with(|| left.path.cmp(&right.path))
});
return Ok(entries);
}
let index = index_entries_from_index(parsed_index);
let head = if head_matches_index {
None
} else {
Some(head_tree_entries(git_dir, format, &db)?)
};
let known_tracked_paths = index.keys().cloned().collect::<BTreeSet<_>>();
let tracked_paths = if options.untracked_mode == StatusUntrackedMode::None {
Some(&known_tracked_paths)
} else {
None
};
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let (worktree, submodule_dirt_map, tracked_presence) =
status_worktree_entries_with_submodule_dirt(
worktree_root,
git_dir,
format,
&stat_cache,
Some(&known_tracked_paths),
tracked_paths,
Some(&mut ignores),
)?;
let mut entries = Vec::new();
if head_matches_index {
collect_status_entries_head_matches_index(
&index,
&worktree,
&tracked_presence,
&stat_cache,
sparse_checkout_active,
&submodule_dirt_map,
options.untracked_mode,
&mut entries,
);
} else if let Some(head) = head.as_ref() {
collect_status_entries_with_head(
StatusComparisonInputs {
head,
index: &index,
worktree: &worktree,
tracked_presence: &tracked_presence,
stat_cache: &stat_cache,
sparse_checkout_active,
submodule_dirt_map: &submodule_dirt_map,
ignores: &ignores,
},
options.untracked_mode,
&mut entries,
);
}
entries.retain(|entry| !unmerged_paths.contains(&entry.path));
entries.append(&mut unmerged_entries);
if options.include_ignored {
let ignored_directory_rows = !matches!(options.untracked_mode, StatusUntrackedMode::All);
let ignored_paths = ignored_untracked_paths(
worktree_root,
git_dir,
&index,
&ignores,
ignored_directory_rows,
)?;
let ignored_paths: Vec<Vec<u8>> = match options.ignored_mode {
StatusIgnoredMode::Matching => ignored_paths,
StatusIgnoredMode::Traditional
if matches!(options.untracked_mode, StatusUntrackedMode::All) =>
{
ignored_paths
}
StatusIgnoredMode::Traditional => {
let mut rolled = BTreeSet::new();
for path in ignored_paths {
let path = ignored_traditional_rollup_path(
worktree_root,
git_dir,
&path,
&index,
&ignores,
)?;
if ignored_traditional_path_is_empty_directory(worktree_root, &path)? {
continue;
}
rolled.insert(path);
}
rolled.into_iter().collect()
}
};
for path in ignored_paths {
entries.push(ShortStatusEntry {
index: b'!',
worktree: b'!',
path,
head_mode: None,
index_mode: None,
worktree_mode: None,
head_oid: None,
index_oid: None,
submodule: None,
});
}
}
let untracked_paths: Vec<Vec<u8>> = match options.untracked_mode {
StatusUntrackedMode::All => worktree
.iter()
.filter_map(|(path, entry)| {
let is_directory = entry.mode == 0o040000 && entry.oid.is_null();
if index.contains_key(path)
|| path_or_parent_is_ignored(&ignores, path, is_directory)
{
return None;
}
if is_directory {
let mut directory = path.clone();
directory.push(b'/');
Some(directory)
} else {
Some(path.clone())
}
})
.collect(),
StatusUntrackedMode::Normal => {
normal_untracked_paths_from_worktree(&worktree, &index, &ignores)
}
StatusUntrackedMode::None => Vec::new(),
};
for path in untracked_paths {
entries.push(ShortStatusEntry {
index: b'?',
worktree: b'?',
path,
head_mode: None,
index_mode: None,
worktree_mode: None,
head_oid: None,
index_oid: None,
submodule: None,
});
}
entries.sort_by(|left, right| {
status_sort_category(left)
.cmp(&status_sort_category(right))
.then_with(|| left.path.cmp(&right.path))
});
Ok(entries)
}
fn short_status_unmerged_entries(index: &Index) -> Vec<ShortStatusEntry> {
let mut by_path: BTreeMap<Vec<u8>, BTreeSet<u16>> = BTreeMap::new();
for entry in &index.entries {
let stage = entry.stage().as_u16();
if stage > 0 {
by_path
.entry(entry.path.as_bytes().to_vec())
.or_default()
.insert(stage);
}
}
by_path
.into_iter()
.map(|(path, stages)| {
let (index, worktree) = short_status_unmerged_codes(&stages);
ShortStatusEntry {
index,
worktree,
path,
head_mode: None,
index_mode: None,
worktree_mode: None,
head_oid: None,
index_oid: None,
submodule: None,
}
})
.collect()
}
fn short_status_unmerged_codes(stages: &BTreeSet<u16>) -> (u8, u8) {
match (
stages.contains(&1),
stages.contains(&2),
stages.contains(&3),
) {
(true, false, false) => (b'D', b'D'),
(false, true, false) => (b'A', b'U'),
(true, true, false) => (b'U', b'D'),
(false, false, true) => (b'U', b'A'),
(true, false, true) => (b'D', b'U'),
(false, true, true) => (b'A', b'A'),
(true, true, true) => (b'U', b'U'),
(false, false, false) => (b'U', b'U'),
}
}
fn sparse_checkout_active_for_status(git_dir: &Path, index: &Index) -> bool {
index.is_sparse()
|| index.entries.iter().any(IndexEntry::is_sparse_dir)
|| sparse_checkout_config_enabled(git_dir)
}
fn sparse_checkout_active_for_borrowed_status(git_dir: &Path, index: &BorrowedIndex<'_>) -> bool {
index
.entries
.iter()
.any(|entry| entry.mode == SPARSE_DIR_MODE && entry.is_skip_worktree())
|| sparse_checkout_config_enabled(git_dir)
}
fn sparse_checkout_config_enabled(git_dir: &Path) -> bool {
GitConfig::read(git_dir.join("config"))
.ok()
.and_then(|config| config.get_bool("core", None, "sparseCheckout"))
== Some(true)
|| GitConfig::read(git_dir.join("config.worktree"))
.ok()
.and_then(|config| config.get_bool("core", None, "sparseCheckout"))
== Some(true)
}
fn collect_status_entries_head_matches_index(
index: &BTreeMap<Vec<u8>, TrackedEntry>,
worktree: &BTreeMap<Vec<u8>, TrackedEntry>,
tracked_presence: &HashSet<Vec<u8>>,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
submodule_dirt_map: &BTreeMap<Vec<u8>, u8>,
untracked_mode: StatusUntrackedMode,
entries: &mut Vec<ShortStatusEntry>,
) {
for (path, index_entry) in index {
let intent_to_add = stat_cache
.index_entry(path)
.is_some_and(IndexEntry::is_intent_to_add);
let visible_index_entry = (!intent_to_add).then_some(index_entry);
let worktree_entry = worktree.get(path);
let worktree_present =
worktree_entry.is_some() || tracked_presence.contains(path.as_slice());
let skip_worktree = sparse_checkout_active
&& stat_cache
.index_entry(path)
.is_some_and(index_entry_skip_worktree);
let submodule = status_submodule_from_entries(
path,
index_entry,
worktree_entry,
submodule_dirt_map,
untracked_mode,
);
let worktree_code = match worktree_entry {
None if intent_to_add => b' ',
None if !worktree_present && skip_worktree => b' ',
None if !worktree_present => b'D',
Some(_) if intent_to_add => b'A',
Some(worktree_entry) if Some(worktree_entry) != visible_index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
if worktree_code != b' ' {
entries.push(ShortStatusEntry {
index: b' ',
worktree: worktree_code,
path: path.clone(),
head_mode: visible_index_entry.map(|entry| entry.mode),
index_mode: visible_index_entry.map(|entry| entry.mode),
worktree_mode: status_worktree_mode(
visible_index_entry,
worktree_entry,
worktree_present,
),
head_oid: visible_index_entry.map(|entry| entry.oid),
index_oid: visible_index_entry.map(|entry| entry.oid),
submodule: submodule.filter(|sub| sub.any()),
});
}
}
}
struct StatusComparisonInputs<'a> {
head: &'a BTreeMap<Vec<u8>, TrackedEntry>,
index: &'a BTreeMap<Vec<u8>, TrackedEntry>,
worktree: &'a BTreeMap<Vec<u8>, TrackedEntry>,
tracked_presence: &'a HashSet<Vec<u8>>,
stat_cache: &'a IndexStatCache,
sparse_checkout_active: bool,
submodule_dirt_map: &'a BTreeMap<Vec<u8>, u8>,
ignores: &'a IgnoreMatcher,
}
fn collect_status_entries_with_head(
inputs: StatusComparisonInputs<'_>,
untracked_mode: StatusUntrackedMode,
entries: &mut Vec<ShortStatusEntry>,
) {
let mut paths = BTreeSet::new();
paths.extend(inputs.head.keys().cloned());
paths.extend(inputs.index.keys().cloned());
paths.extend(
inputs
.worktree
.keys()
.filter(|path| inputs.index.contains_key(*path))
.cloned(),
);
for path in paths {
let head_entry = inputs.head.get(&path);
let index_entry = inputs.index.get(&path);
let intent_to_add = inputs
.stat_cache
.index_entry(&path)
.is_some_and(IndexEntry::is_intent_to_add);
let visible_index_entry = index_entry.filter(|_| !intent_to_add);
let worktree_entry = inputs.worktree.get(&path);
let worktree_present =
worktree_entry.is_some() || inputs.tracked_presence.contains(path.as_slice());
if head_entry.is_none()
&& index_entry.is_none()
&& worktree_entry.is_some()
&& inputs.ignores.is_ignored(&path, false)
{
continue;
}
let submodule = match visible_index_entry {
Some(index_entry) => status_submodule_from_entries(
&path,
index_entry,
worktree_entry,
inputs.submodule_dirt_map,
untracked_mode,
),
None => None,
};
let skip_worktree = inputs.sparse_checkout_active
&& visible_index_entry.is_some_and(|_| {
inputs
.stat_cache
.index_entry(&path)
.is_some_and(index_entry_skip_worktree)
});
let (index_code, worktree_code) =
if head_entry.is_none() && index_entry.is_none() && worktree_entry.is_some() {
(b'?', b'?')
} else {
let index_code = match (head_entry, visible_index_entry) {
(None, Some(_)) => b'A',
(Some(_), None) => b'D',
(Some(left), Some(right)) if left != right => b'M',
_ => b' ',
};
let worktree_code = match (visible_index_entry, worktree_entry) {
(None, Some(_)) if intent_to_add => b'A',
(None, Some(_)) => b'?',
(None, None) if intent_to_add => b' ',
(Some(_), None) if !worktree_present && skip_worktree => b' ',
(Some(_), None) if !worktree_present => b'D',
(Some(left), Some(right)) if left != right => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
(index_code, worktree_code)
};
if index_code != b' ' || worktree_code != b' ' {
let worktree_mode = if skip_worktree && !worktree_present && worktree_entry.is_none() {
visible_index_entry.map(|entry| entry.mode)
} else {
status_worktree_mode(visible_index_entry, worktree_entry, worktree_present)
};
entries.push(ShortStatusEntry {
index: index_code,
worktree: worktree_code,
path,
head_mode: head_entry.map(|entry| entry.mode),
index_mode: visible_index_entry.map(|entry| entry.mode),
worktree_mode,
head_oid: head_entry.map(|entry| entry.oid),
index_oid: visible_index_entry.map(|entry| entry.oid),
submodule: submodule.filter(|sub| sub.any()),
});
}
}
}
fn status_worktree_mode(
index_entry: Option<&TrackedEntry>,
worktree_entry: Option<&TrackedEntry>,
worktree_present: bool,
) -> Option<u32> {
worktree_entry.map(|entry| entry.mode).or_else(|| {
worktree_present
.then(|| index_entry.map(|entry| entry.mode))
.flatten()
})
}
fn status_submodule_from_entries(
path: &[u8],
index_entry: &TrackedEntry,
worktree_entry: Option<&TrackedEntry>,
submodule_dirt_map: &BTreeMap<Vec<u8>, u8>,
_untracked_mode: StatusUntrackedMode,
) -> Option<SubmoduleStatus> {
let worktree_entry = worktree_entry?;
if !sley_index::is_gitlink(index_entry.mode) || !sley_index::is_gitlink(worktree_entry.mode) {
return None;
}
let dirt = submodule_dirt_map.get(path).copied().unwrap_or(0);
Some(SubmoduleStatus {
new_commits: index_entry.oid != worktree_entry.oid,
modified_content: dirt & DIRTY_SUBMODULE_MODIFIED != 0,
untracked_content: dirt & DIRTY_SUBMODULE_UNTRACKED != 0,
})
}
fn short_status_tracked_only(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
index: &Index,
stat_cache: &IndexStatCache,
head_matches_index: bool,
sparse_checkout_active: bool,
untracked_mode: StatusUntrackedMode,
) -> Result<Vec<ShortStatusEntry>> {
let normal_entry_count = index
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.count();
if head_matches_index && normal_entry_count >= 512 {
return short_status_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
index,
stat_cache,
sparse_checkout_active,
untracked_mode,
);
}
let head = if head_matches_index {
None
} else {
Some(head_tree_entries(git_dir, format, db)?)
};
if !head_matches_index && normal_entry_count >= 512 {
if let Some(head) = head.as_ref() {
return short_status_tracked_only_with_head_parallel(
worktree_root,
git_dir,
format,
index,
stat_cache,
head,
sparse_checkout_active,
untracked_mode,
);
}
}
let mut clean_filter = None;
let mut entries = Vec::new();
for entry in index
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
{
let path = entry.path.as_bytes();
let index_entry = TrackedEntry {
mode: entry.mode,
oid: entry.oid,
};
let head_entry = if head_matches_index {
(!entry.is_intent_to_add()).then_some(&index_entry)
} else {
head.as_ref().and_then(|head| head.get(path))
};
let worktree_entry = worktree_entry_for_index_entry_with_attributes(
worktree_root,
git_dir,
format,
entry,
stat_cache,
&mut clean_filter,
)?;
let submodule = tracked_only_submodule_status(
worktree_root,
path,
&index_entry,
worktree_entry.as_ref(),
untracked_mode,
)?;
let visible_index_entry = (!entry.is_intent_to_add()).then_some(&index_entry);
let index_code = match (head_entry, visible_index_entry) {
(None, Some(_)) => b'A',
(Some(_), None) => b'D',
(Some(head_entry), Some(index_entry)) if *head_entry != *index_entry => b'M',
_ => b' ',
};
let worktree_code = match worktree_entry.as_ref() {
None if entry.is_intent_to_add() => b' ',
None if sparse_checkout_active && entry.is_skip_worktree() => b' ',
None => b'D',
Some(_) if entry.is_intent_to_add() => b'A',
Some(worktree_entry) if Some(worktree_entry) != visible_index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
if index_code != b' ' || worktree_code != b' ' {
entries.push(ShortStatusEntry {
index: index_code,
worktree: worktree_code,
path: path.to_vec(),
head_mode: head_entry.map(|entry| entry.mode),
index_mode: visible_index_entry.map(|entry| entry.mode),
worktree_mode: worktree_entry.as_ref().map(|entry| entry.mode),
head_oid: head_entry.map(|entry| entry.oid),
index_oid: visible_index_entry.map(|entry| entry.oid),
submodule: submodule.filter(|sub| sub.any()),
});
}
}
if let Some(head) = head.as_ref() {
let index_paths = index
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.map(|entry| entry.path.as_bytes().to_vec())
.collect::<HashSet<_>>();
for (path, head_entry) in head {
if index_paths.contains(path.as_slice()) {
continue;
}
entries.push(ShortStatusEntry {
index: b'D',
worktree: b' ',
path: path.clone(),
head_mode: Some(head_entry.mode),
index_mode: None,
worktree_mode: None,
head_oid: Some(head_entry.oid),
index_oid: None,
submodule: None,
});
}
}
entries.sort_by(|left, right| {
status_sort_category(left)
.cmp(&status_sort_category(right))
.then_with(|| left.path.cmp(&right.path))
});
Ok(entries)
}
fn short_status_borrowed_head_matches_index_if_possible(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
untracked_mode: StatusUntrackedMode,
) -> Result<Option<Vec<ShortStatusEntry>>> {
let index_path = repository_index_path(git_dir);
let index_metadata = match fs::metadata(&index_path) {
Ok(metadata) => metadata,
Err(err)
if err.kind() == std::io::ErrorKind::NotFound
&& matches!(untracked_mode, StatusUntrackedMode::None) =>
{
return Ok(Some(Vec::new()));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let index_bytes = read_borrowed_index_bytes(&index_path)?;
status_profile_mem(
"after_index_bytes",
&[
("index_file_bytes", index_metadata.len() as usize),
("index_bytes_len", index_bytes.as_ref().len()),
(
"index_bytes_mapped",
usize::from(matches!(index_bytes, BorrowedIndexBytes::Mapped(_))),
),
],
);
let borrowed = match BorrowedIndex::parse(index_bytes.as_ref(), format) {
Ok(index) => index,
Err(GitError::Unsupported(_)) => return Ok(None),
Err(err) => return Err(err),
};
status_profile_mem(
"after_borrowed_parse",
&[
("index_file_bytes", index_metadata.len() as usize),
("index_bytes_len", index_bytes.as_ref().len()),
(
"index_bytes_mapped",
usize::from(matches!(index_bytes, BorrowedIndexBytes::Mapped(_))),
),
("borrowed_entries_len", borrowed.entries.len()),
("borrowed_entries_cap", borrowed.entries.capacity()),
(
"borrowed_entry_size",
std::mem::size_of::<IndexEntryRef<'_>>(),
),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
("borrowed_extensions_len", borrowed.extensions.len()),
],
);
let sparse_checkout_active = sparse_checkout_active_for_borrowed_status(git_dir, &borrowed);
if borrowed
.entries
.iter()
.any(|entry| entry.mode == SPARSE_DIR_MODE && entry.is_skip_worktree())
{
return Ok(None);
}
if borrowed
.entries
.iter()
.any(|entry| entry.stage() != Stage::Normal)
{
return Ok(None);
}
status_profile_mem(
"after_sparse_scan",
&[
("borrowed_entries_len", borrowed.entries.len()),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
(
"sparse_checkout_active",
usize::from(sparse_checkout_active),
),
],
);
let Some(head_tree_oid) = resolve_head_tree_oid(git_dir, format, db)? else {
return Ok(None);
};
status_profile_mem(
"after_head_tree_oid",
&[
("borrowed_entries_len", borrowed.entries.len()),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
],
);
let stage0_entry_count = borrowed
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.count();
status_profile_mem(
"after_stage0_count",
&[
("stage0_entry_count", stage0_entry_count),
("borrowed_entries_len", borrowed.entries.len()),
],
);
if !head_matches_borrowed_index_from_cache_tree(
&borrowed,
format,
&head_tree_oid,
stage0_entry_count,
)? {
return Ok(None);
}
status_profile_mem(
"after_head_matches_index",
&[
("stage0_entry_count", stage0_entry_count),
("borrowed_entries_len", borrowed.entries.len()),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
],
);
let index_mtime = file_mtime_parts(&index_metadata);
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
let profile_enabled = StatusProfileCounters::enabled();
let mut profile = profile_enabled.then(|| StatusProfileCounters {
fast_path_borrowed: true,
..StatusProfileCounters::default()
});
if matches!(untracked_mode, StatusUntrackedMode::None) {
let tracked_start = Instant::now();
let entries = short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)?;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_start.elapsed().as_micros();
profile.emit();
}
return Ok(Some(entries));
}
if stage0_entry_count < STATUS_BORROWED_OVERLAP_MIN_STAGE0 {
let tracked_start = Instant::now();
let mut entries = short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)?;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_start.elapsed().as_micros();
}
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let untracked_start = Instant::now();
let untracked_paths = status_untracked_paths_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
profile.as_mut(),
)?;
if let Some(profile) = profile.as_mut() {
profile.untracked_elapsed_us = untracked_start.elapsed().as_micros();
profile.untracked_rows = untracked_paths.len() as u64;
}
let render_start = Instant::now();
append_untracked_status_entries(&mut entries, untracked_paths);
if let Some(profile) = profile.as_mut() {
profile.render_elapsed_us = render_start.elapsed().as_micros();
profile.emit();
}
return Ok(Some(entries));
}
if let Some(profile) = profile.as_mut() {
profile.overlap_enabled = true;
}
if profile_enabled {
let (mut entries, untracked_paths, untracked_profile) =
std::thread::scope(|scope| -> Result<_> {
let tracked = spawn_status_worker(scope, "status-tracked", || {
let start = Instant::now();
short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)
.map(|entries| (entries, start.elapsed().as_micros()))
})?;
let untracked = spawn_status_worker(
scope,
"status-untracked",
|| -> Result<(Vec<Vec<u8>>, StatusProfileCounters)> {
let mut local_profile = StatusProfileCounters::default();
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let start = Instant::now();
let paths = status_untracked_paths_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
Some(&mut local_profile),
)?;
local_profile.untracked_elapsed_us = start.elapsed().as_micros();
local_profile.untracked_rows = paths.len() as u64;
Ok((paths, local_profile))
},
)?;
let (entries, tracked_elapsed_us) = tracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
let (untracked_paths, untracked_profile) = untracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_elapsed_us;
}
Ok((entries, untracked_paths, Some(untracked_profile)))
})?;
if let Some(profile) = profile.as_mut() {
if let Some(untracked_profile) = untracked_profile {
profile.merge_untracked(untracked_profile);
}
}
let render_start = Instant::now();
append_untracked_status_entries(&mut entries, untracked_paths);
if let Some(profile) = profile.as_mut() {
profile.render_elapsed_us = render_start.elapsed().as_micros();
profile.emit();
}
return Ok(Some(entries));
}
let (mut entries, untracked_paths) = std::thread::scope(|scope| -> Result<_> {
let tracked = spawn_status_worker(scope, "status-tracked", || {
short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)
})?;
let untracked =
spawn_status_worker(scope, "status-untracked", || -> Result<Vec<Vec<u8>>> {
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
status_untracked_paths_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
None,
)
})?;
let entries = tracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
let untracked_paths = untracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
Ok((entries, untracked_paths))
})?;
let render_start = Instant::now();
append_untracked_status_entries(&mut entries, untracked_paths);
if let Some(profile) = profile.as_mut() {
profile.render_elapsed_us = render_start.elapsed().as_micros();
profile.emit();
}
Ok(Some(entries))
}
fn stream_short_status_borrowed_head_matches_index_if_possible<F>(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
untracked_mode: StatusUntrackedMode,
emit: &mut F,
) -> Result<Option<()>>
where
F: for<'a> FnMut(ShortStatusRow<'a>) -> Result<StreamControl>,
{
let index_path = repository_index_path(git_dir);
let index_metadata = match fs::metadata(&index_path) {
Ok(metadata) => metadata,
Err(err)
if err.kind() == std::io::ErrorKind::NotFound
&& matches!(untracked_mode, StatusUntrackedMode::None) =>
{
return Ok(Some(()));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let index_bytes = read_borrowed_index_bytes(&index_path)?;
status_profile_mem(
"after_index_bytes",
&[
("index_file_bytes", index_metadata.len() as usize),
("index_bytes_len", index_bytes.as_ref().len()),
(
"index_bytes_mapped",
usize::from(matches!(index_bytes, BorrowedIndexBytes::Mapped(_))),
),
],
);
let borrowed = match BorrowedIndex::parse(index_bytes.as_ref(), format) {
Ok(index) => index,
Err(GitError::Unsupported(_)) => return Ok(None),
Err(err) => return Err(err),
};
status_profile_mem(
"after_borrowed_parse",
&[
("index_file_bytes", index_metadata.len() as usize),
("index_bytes_len", index_bytes.as_ref().len()),
(
"index_bytes_mapped",
usize::from(matches!(index_bytes, BorrowedIndexBytes::Mapped(_))),
),
("borrowed_entries_len", borrowed.entries.len()),
("borrowed_entries_cap", borrowed.entries.capacity()),
(
"borrowed_entry_size",
std::mem::size_of::<IndexEntryRef<'_>>(),
),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
("borrowed_extensions_len", borrowed.extensions.len()),
],
);
let sparse_checkout_active = sparse_checkout_active_for_borrowed_status(git_dir, &borrowed);
if borrowed
.entries
.iter()
.any(|entry| entry.mode == SPARSE_DIR_MODE && entry.is_skip_worktree())
{
return Ok(None);
}
if borrowed
.entries
.iter()
.any(|entry| entry.stage() != Stage::Normal)
{
return Ok(None);
}
status_profile_mem(
"after_sparse_scan",
&[
("borrowed_entries_len", borrowed.entries.len()),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
(
"sparse_checkout_active",
usize::from(sparse_checkout_active),
),
],
);
let Some(head_tree_oid) = resolve_head_tree_oid(git_dir, format, db)? else {
return Ok(None);
};
status_profile_mem(
"after_head_tree_oid",
&[
("borrowed_entries_len", borrowed.entries.len()),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
],
);
let stage0_entry_count = borrowed
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.count();
status_profile_mem(
"after_stage0_count",
&[
("stage0_entry_count", stage0_entry_count),
("borrowed_entries_len", borrowed.entries.len()),
],
);
if !head_matches_borrowed_index_from_cache_tree(
&borrowed,
format,
&head_tree_oid,
stage0_entry_count,
)? {
return Ok(None);
}
status_profile_mem(
"after_head_matches_index",
&[
("stage0_entry_count", stage0_entry_count),
("borrowed_entries_len", borrowed.entries.len()),
(
"borrowed_entries_cap_bytes",
borrowed.entries.capacity() * std::mem::size_of::<IndexEntryRef<'_>>(),
),
],
);
let index_mtime = file_mtime_parts(&index_metadata);
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
let profile_enabled = StatusProfileCounters::enabled();
let mut profile = profile_enabled.then(|| StatusProfileCounters {
fast_path_borrowed: true,
..StatusProfileCounters::default()
});
if matches!(untracked_mode, StatusUntrackedMode::None) {
let tracked_start = Instant::now();
let tracked_control =
stream_short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
emit,
)?;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_start.elapsed().as_micros();
}
if let Some(profile) = profile.as_ref() {
profile.emit();
}
if tracked_control.is_stop() {
return Ok(Some(()));
}
return Ok(Some(()));
}
if stage0_entry_count < STATUS_BORROWED_OVERLAP_MIN_STAGE0 {
let tracked_start = Instant::now();
let tracked_control =
stream_short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
emit,
)?;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_start.elapsed().as_micros();
}
if tracked_control.is_stop() {
if let Some(profile) = profile.as_ref() {
profile.emit();
}
return Ok(Some(()));
}
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let untracked_start = Instant::now();
stream_status_untracked_paths_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
profile.as_mut(),
emit_untracked_status_entry(emit),
)?;
if let Some(profile) = profile.as_mut() {
profile.untracked_elapsed_us = untracked_start.elapsed().as_micros();
profile.emit();
}
return Ok(Some(()));
}
if let Some(profile) = profile.as_mut() {
profile.overlap_enabled = true;
}
let (tracked_control, untracked_paths, untracked_profile) =
std::thread::scope(|scope| -> Result<_> {
let untracked = spawn_status_worker(
scope,
"status-untracked",
|| -> Result<(Vec<Vec<u8>>, StatusProfileCounters)> {
let mut local_profile = StatusProfileCounters::default();
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
ignores.emit_memory_profile("after_untracked_ignore");
let start = Instant::now();
let paths = status_untracked_paths_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
profile_enabled.then_some(&mut local_profile),
)?;
status_profile_mem(
"after_untracked_collect",
&[
("untracked_paths_len", paths.len()),
("untracked_paths_cap", paths.capacity()),
(
"untracked_paths_cap_bytes",
paths.capacity() * std::mem::size_of::<Vec<u8>>(),
),
(
"untracked_path_payload_bytes",
paths.iter().map(Vec::capacity).sum(),
),
],
);
local_profile.untracked_elapsed_us = start.elapsed().as_micros();
local_profile.untracked_rows = paths.len() as u64;
Ok((paths, local_profile))
},
)?;
let tracked_start = Instant::now();
let tracked_control =
stream_short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
emit,
)?;
let tracked_elapsed_us = tracked_start.elapsed().as_micros();
let (untracked_paths, untracked_profile) = untracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_elapsed_us;
}
Ok((
tracked_control,
untracked_paths,
profile_enabled.then_some(untracked_profile),
))
})?;
status_profile_mem(
"after_join",
&[
("untracked_paths_len", untracked_paths.len()),
("untracked_paths_cap", untracked_paths.capacity()),
(
"untracked_paths_cap_bytes",
untracked_paths.capacity() * std::mem::size_of::<Vec<u8>>(),
),
(
"untracked_path_payload_bytes",
untracked_paths.iter().map(Vec::capacity).sum(),
),
],
);
if tracked_control.is_stop() {
if let Some(profile) = profile.as_mut()
&& let Some(untracked_profile) = untracked_profile
{
profile.merge_untracked(untracked_profile);
profile.emit();
}
return Ok(Some(()));
}
if let Some(profile) = profile.as_mut()
&& let Some(untracked_profile) = untracked_profile
{
profile.merge_untracked(untracked_profile);
}
let render_start = Instant::now();
for path in untracked_paths {
let row = untracked_status_row(&path);
if emit(row)?.is_stop() {
break;
}
}
if let Some(profile) = profile.as_mut() {
profile.render_elapsed_us = render_start.elapsed().as_micros();
profile.emit();
}
status_profile_mem("after_render", &[]);
Ok(Some(()))
}
fn short_status_borrowed_head_matches_index_count_if_possible(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
untracked_mode: StatusUntrackedMode,
) -> Result<Option<usize>> {
let index_path = repository_index_path(git_dir);
let index_metadata = match fs::metadata(&index_path) {
Ok(metadata) => metadata,
Err(err)
if err.kind() == std::io::ErrorKind::NotFound
&& matches!(untracked_mode, StatusUntrackedMode::None) =>
{
return Ok(Some(0));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let index_bytes = read_borrowed_index_bytes(&index_path)?;
let borrowed = match BorrowedIndex::parse(index_bytes.as_ref(), format) {
Ok(index) => index,
Err(GitError::Unsupported(_)) => return Ok(None),
Err(err) => return Err(err),
};
let sparse_checkout_active = sparse_checkout_active_for_borrowed_status(git_dir, &borrowed);
if borrowed
.entries
.iter()
.any(|entry| entry.mode == SPARSE_DIR_MODE && entry.is_skip_worktree())
{
return Ok(None);
}
let Some(head_tree_oid) = resolve_head_tree_oid(git_dir, format, db)? else {
return Ok(None);
};
let stage0_entry_count = borrowed
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.count();
if !head_matches_borrowed_index_from_cache_tree(
&borrowed,
format,
&head_tree_oid,
stage0_entry_count,
)? {
return Ok(None);
}
let index_mtime = file_mtime_parts(&index_metadata);
let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
let profile_enabled = StatusProfileCounters::enabled();
let mut profile = profile_enabled.then(|| StatusProfileCounters {
fast_path_borrowed: true,
..StatusProfileCounters::default()
});
if matches!(untracked_mode, StatusUntrackedMode::None) {
let tracked_start = Instant::now();
let count = short_status_borrowed_tracked_only_head_matches_index_count_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)?;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_start.elapsed().as_micros();
profile.emit();
}
return Ok(Some(count));
}
if stage0_entry_count < STATUS_BORROWED_OVERLAP_MIN_STAGE0 {
let tracked_start = Instant::now();
let tracked_count = short_status_borrowed_tracked_only_head_matches_index_count_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)?;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_start.elapsed().as_micros();
}
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let untracked_start = Instant::now();
let untracked_count = status_untracked_count_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
profile.as_mut(),
)?;
if let Some(profile) = profile.as_mut() {
profile.untracked_elapsed_us = untracked_start.elapsed().as_micros();
profile.untracked_rows = untracked_count as u64;
profile.emit();
}
return Ok(Some(tracked_count + untracked_count));
}
if let Some(profile) = profile.as_mut() {
profile.overlap_enabled = true;
}
let (tracked_count, untracked_count, untracked_profile) =
std::thread::scope(|scope| -> Result<_> {
let tracked = spawn_status_worker(scope, "status-tracked", || {
let start = Instant::now();
short_status_borrowed_tracked_only_head_matches_index_count_parallel(
worktree_root,
git_dir,
format,
&borrowed,
&stat_cache,
sparse_checkout_active,
untracked_mode,
)
.map(|count| (count, start.elapsed().as_micros()))
})?;
let untracked = spawn_status_worker(
scope,
"status-untracked",
|| -> Result<(usize, StatusProfileCounters)> {
let mut local_profile = StatusProfileCounters::default();
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let start = Instant::now();
let count = status_untracked_count_from_borrowed_index(
worktree_root,
git_dir,
&borrowed,
&mut ignores,
untracked_mode,
profile_enabled.then_some(&mut local_profile),
)?;
local_profile.untracked_elapsed_us = start.elapsed().as_micros();
local_profile.untracked_rows = count as u64;
Ok((count, local_profile))
},
)?;
let (tracked_count, tracked_elapsed_us) = tracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
let (untracked_count, untracked_profile) = untracked
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
if let Some(profile) = profile.as_mut() {
profile.tracked_elapsed_us = tracked_elapsed_us;
}
Ok((
tracked_count,
untracked_count,
profile_enabled.then_some(untracked_profile),
))
})?;
if let Some(profile) = profile.as_mut() {
if let Some(untracked_profile) = untracked_profile {
profile.merge_untracked(untracked_profile);
}
profile.emit();
}
Ok(Some(tracked_count + untracked_count))
}
fn emit_untracked_status_entry<'a, F>(
emit: &'a mut F,
) -> impl FnMut(&[u8]) -> Result<StreamControl> + 'a
where
F: for<'row> FnMut(ShortStatusRow<'row>) -> Result<StreamControl>,
{
|path| emit(untracked_status_row(path))
}
fn untracked_status_entry(path: Vec<u8>) -> ShortStatusEntry {
ShortStatusEntry {
index: b'?',
worktree: b'?',
path,
head_mode: None,
index_mode: None,
worktree_mode: None,
head_oid: None,
index_oid: None,
submodule: None,
}
}
fn untracked_status_row(path: &[u8]) -> ShortStatusRow<'_> {
ShortStatusRow {
index: b'?',
worktree: b'?',
path,
head_mode: None,
index_mode: None,
worktree_mode: None,
head_oid: None,
index_oid: None,
submodule: None,
}
}
fn append_untracked_status_entries(
entries: &mut Vec<ShortStatusEntry>,
untracked_paths: Vec<Vec<u8>>,
) {
for path in untracked_paths {
entries.push(untracked_status_entry(path));
}
}
#[derive(Debug, Clone, Copy)]
enum TrackedOnlyPrecheck {
Deleted(usize),
Slow(usize),
}
#[derive(Debug)]
enum TrackedOnlyPrecheckOutcome {
Clean,
Deleted,
Slow,
}
fn short_status_tracked_only_head_matches_index_parallel(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index: &Index,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
untracked_mode: StatusUntrackedMode,
) -> Result<Vec<ShortStatusEntry>> {
let prechecks = tracked_only_non_clean_prechecks_parallel(
worktree_root,
index,
stat_cache,
sparse_checkout_active,
)?;
let mut clean_filter = None;
let mut entries = Vec::new();
for precheck in prechecks {
match precheck {
TrackedOnlyPrecheck::Deleted(idx) => {
let entry = &index.entries[idx];
if entry.is_intent_to_add() {
continue;
}
let path = entry.path.as_bytes();
entries.push(ShortStatusEntry {
index: b' ',
worktree: b'D',
path: path.to_vec(),
head_mode: Some(entry.mode),
index_mode: Some(entry.mode),
worktree_mode: None,
head_oid: Some(entry.oid),
index_oid: Some(entry.oid),
submodule: None,
});
}
TrackedOnlyPrecheck::Slow(idx) => {
let entry = &index.entries[idx];
let path = entry.path.as_bytes();
let index_entry = TrackedEntry {
mode: entry.mode,
oid: entry.oid,
};
let worktree_entry = worktree_entry_for_index_entry_with_attributes(
worktree_root,
git_dir,
format,
entry,
stat_cache,
&mut clean_filter,
)?;
let submodule = tracked_only_submodule_status(
worktree_root,
path,
&index_entry,
worktree_entry.as_ref(),
untracked_mode,
)?;
let worktree_code = match worktree_entry.as_ref() {
None if entry.is_intent_to_add() => b' ',
None => b'D',
Some(_) if entry.is_intent_to_add() => b'A',
Some(worktree_entry) if *worktree_entry != index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
if worktree_code != b' ' {
entries.push(ShortStatusEntry {
index: b' ',
worktree: worktree_code,
path: path.to_vec(),
head_mode: (!entry.is_intent_to_add()).then_some(index_entry.mode),
index_mode: (!entry.is_intent_to_add()).then_some(index_entry.mode),
worktree_mode: worktree_entry.as_ref().map(|entry| entry.mode),
head_oid: (!entry.is_intent_to_add()).then_some(index_entry.oid),
index_oid: (!entry.is_intent_to_add()).then_some(index_entry.oid),
submodule: submodule.filter(|sub| sub.any()),
});
}
}
}
}
entries.sort_by(|left, right| {
status_sort_category(left)
.cmp(&status_sort_category(right))
.then_with(|| left.path.cmp(&right.path))
});
Ok(entries)
}
fn short_status_borrowed_tracked_only_head_matches_index_parallel(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index: &BorrowedIndex<'_>,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
untracked_mode: StatusUntrackedMode,
) -> Result<Vec<ShortStatusEntry>> {
let prechecks = tracked_only_borrowed_non_clean_prechecks_parallel(
worktree_root,
index,
stat_cache,
sparse_checkout_active,
)?;
let mut clean_filter = None;
let mut entries = Vec::new();
for precheck in prechecks {
match precheck {
TrackedOnlyPrecheck::Deleted(idx) => {
let entry = &index.entries[idx];
if entry.is_intent_to_add() {
continue;
}
entries.push(ShortStatusEntry {
index: b' ',
worktree: b'D',
path: entry.path.to_vec(),
head_mode: Some(entry.mode),
index_mode: Some(entry.mode),
worktree_mode: None,
head_oid: Some(entry.oid),
index_oid: Some(entry.oid),
submodule: None,
});
}
TrackedOnlyPrecheck::Slow(idx) => {
let entry = &index.entries[idx];
let index_entry = TrackedEntry {
mode: entry.mode,
oid: entry.oid,
};
let worktree_entry = worktree_entry_for_index_entry_ref_with_attributes(
worktree_root,
git_dir,
format,
entry,
stat_cache,
&mut clean_filter,
)?;
let submodule = tracked_only_submodule_status(
worktree_root,
entry.path,
&index_entry,
worktree_entry.as_ref(),
untracked_mode,
)?;
let worktree_code = match worktree_entry.as_ref() {
None if entry.is_intent_to_add() => b' ',
None => b'D',
Some(_) if entry.is_intent_to_add() => b'A',
Some(worktree_entry) if *worktree_entry != index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
if worktree_code != b' ' {
entries.push(ShortStatusEntry {
index: b' ',
worktree: worktree_code,
path: entry.path.to_vec(),
head_mode: (!entry.is_intent_to_add()).then_some(index_entry.mode),
index_mode: (!entry.is_intent_to_add()).then_some(index_entry.mode),
worktree_mode: worktree_entry.as_ref().map(|entry| entry.mode),
head_oid: (!entry.is_intent_to_add()).then_some(index_entry.oid),
index_oid: (!entry.is_intent_to_add()).then_some(index_entry.oid),
submodule: submodule.filter(|sub| sub.any()),
});
}
}
}
}
entries.sort_by(|left, right| {
status_sort_category(left)
.cmp(&status_sort_category(right))
.then_with(|| left.path.cmp(&right.path))
});
Ok(entries)
}
fn stream_short_status_borrowed_tracked_only_head_matches_index_parallel<F>(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index: &BorrowedIndex<'_>,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
untracked_mode: StatusUntrackedMode,
emit: &mut F,
) -> Result<StreamControl>
where
F: for<'a> FnMut(ShortStatusRow<'a>) -> Result<StreamControl>,
{
let prechecks = tracked_only_borrowed_non_clean_prechecks_parallel(
worktree_root,
index,
stat_cache,
sparse_checkout_active,
)?;
let mut clean_filter = None;
for precheck in prechecks {
match precheck {
TrackedOnlyPrecheck::Deleted(idx) => {
let entry = &index.entries[idx];
if entry.is_intent_to_add() {
continue;
}
if emit(ShortStatusRow {
index: b' ',
worktree: b'D',
path: entry.path,
head_mode: Some(entry.mode),
index_mode: Some(entry.mode),
worktree_mode: None,
head_oid: Some(entry.oid),
index_oid: Some(entry.oid),
submodule: None,
})?
.is_stop()
{
return Ok(StreamControl::Stop);
}
}
TrackedOnlyPrecheck::Slow(idx) => {
let entry = &index.entries[idx];
let index_entry = TrackedEntry {
mode: entry.mode,
oid: entry.oid,
};
let worktree_entry = worktree_entry_for_index_entry_ref_with_attributes(
worktree_root,
git_dir,
format,
entry,
stat_cache,
&mut clean_filter,
)?;
let submodule = tracked_only_submodule_status(
worktree_root,
entry.path,
&index_entry,
worktree_entry.as_ref(),
untracked_mode,
)?;
let worktree_code = match worktree_entry.as_ref() {
None if entry.is_intent_to_add() => b' ',
None => b'D',
Some(_) if entry.is_intent_to_add() => b'A',
Some(worktree_entry) if *worktree_entry != index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
if worktree_code != b' ' {
if emit(ShortStatusRow {
index: b' ',
worktree: worktree_code,
path: entry.path,
head_mode: (!entry.is_intent_to_add()).then_some(index_entry.mode),
index_mode: (!entry.is_intent_to_add()).then_some(index_entry.mode),
worktree_mode: worktree_entry.as_ref().map(|entry| entry.mode),
head_oid: (!entry.is_intent_to_add()).then_some(index_entry.oid),
index_oid: (!entry.is_intent_to_add()).then_some(index_entry.oid),
submodule: submodule.filter(|sub| sub.any()),
})?
.is_stop()
{
return Ok(StreamControl::Stop);
}
}
}
}
}
Ok(StreamControl::Continue)
}
fn short_status_borrowed_tracked_only_head_matches_index_count_parallel(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index: &BorrowedIndex<'_>,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
untracked_mode: StatusUntrackedMode,
) -> Result<usize> {
let prechecks = tracked_only_borrowed_non_clean_prechecks_parallel(
worktree_root,
index,
stat_cache,
sparse_checkout_active,
)?;
let mut clean_filter = None;
let mut count = 0usize;
for precheck in prechecks {
match precheck {
TrackedOnlyPrecheck::Deleted(_) => count += 1,
TrackedOnlyPrecheck::Slow(idx) => {
let entry = &index.entries[idx];
let index_entry = TrackedEntry {
mode: entry.mode,
oid: entry.oid,
};
let worktree_entry = worktree_entry_for_index_entry_ref_with_attributes(
worktree_root,
git_dir,
format,
entry,
stat_cache,
&mut clean_filter,
)?;
let submodule = tracked_only_submodule_status(
worktree_root,
entry.path,
&index_entry,
worktree_entry.as_ref(),
untracked_mode,
)?;
let worktree_code = match worktree_entry.as_ref() {
None => b'D',
Some(worktree_entry) if *worktree_entry != index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
if worktree_code != b' ' {
count += 1;
}
}
}
}
Ok(count)
}
fn short_status_tracked_only_with_head_parallel(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index: &Index,
stat_cache: &IndexStatCache,
head: &BTreeMap<Vec<u8>, TrackedEntry>,
sparse_checkout_active: bool,
untracked_mode: StatusUntrackedMode,
) -> Result<Vec<ShortStatusEntry>> {
let prechecks = tracked_only_non_clean_prechecks_parallel(
worktree_root,
index,
stat_cache,
sparse_checkout_active,
)?;
let mut precheck_cursor = 0usize;
let mut clean_filter = None;
let mut entries = Vec::new();
for (idx, entry) in index.entries.iter().enumerate() {
if entry.stage() != Stage::Normal {
continue;
}
let path = entry.path.as_bytes();
let index_entry = TrackedEntry {
mode: entry.mode,
oid: entry.oid,
};
let head_entry = head.get(path);
let visible_index_entry = (!entry.is_intent_to_add()).then_some(&index_entry);
let index_code = match (head_entry, visible_index_entry) {
(None, Some(_)) => b'A',
(Some(_), None) => b'D',
(Some(head_entry), Some(index_entry)) if *head_entry != *index_entry => b'M',
_ => b' ',
};
let precheck = prechecks
.get(precheck_cursor)
.copied()
.and_then(|precheck| {
if tracked_only_precheck_index(precheck) == idx {
precheck_cursor += 1;
Some(precheck)
} else {
None
}
});
let (worktree_code, worktree_mode, submodule) = match precheck {
None if entry.is_intent_to_add() => (b' ', None, None),
None => (b' ', Some(index_entry.mode), None),
Some(TrackedOnlyPrecheck::Deleted(_)) if entry.is_intent_to_add() => {
(b' ', None, None)
}
Some(TrackedOnlyPrecheck::Deleted(_)) => (b'D', None, None),
Some(TrackedOnlyPrecheck::Slow(_)) => {
let worktree_entry = worktree_entry_for_index_entry_with_attributes(
worktree_root,
git_dir,
format,
entry,
stat_cache,
&mut clean_filter,
)?;
let submodule = tracked_only_submodule_status(
worktree_root,
path,
&index_entry,
worktree_entry.as_ref(),
untracked_mode,
)?;
let worktree_code = match worktree_entry.as_ref() {
None if entry.is_intent_to_add() => b' ',
None => b'D',
Some(_) if entry.is_intent_to_add() => b'A',
Some(worktree_entry) if *worktree_entry != index_entry => b'M',
_ if submodule.is_some_and(|sub| sub.any()) => b'M',
_ => b' ',
};
(
worktree_code,
worktree_entry.as_ref().map(|entry| entry.mode),
submodule.filter(|sub| sub.any()),
)
}
};
if index_code != b' ' || worktree_code != b' ' {
entries.push(ShortStatusEntry {
index: index_code,
worktree: worktree_code,
path: path.to_vec(),
head_mode: head_entry.map(|entry| entry.mode),
index_mode: visible_index_entry.map(|entry| entry.mode),
worktree_mode,
head_oid: head_entry.map(|entry| entry.oid),
index_oid: visible_index_entry.map(|entry| entry.oid),
submodule,
});
}
}
let index_paths = index
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.map(|entry| entry.path.as_bytes().to_vec())
.collect::<HashSet<_>>();
for (path, head_entry) in head {
if index_paths.contains(path.as_slice()) {
continue;
}
entries.push(ShortStatusEntry {
index: b'D',
worktree: b' ',
path: path.clone(),
head_mode: Some(head_entry.mode),
index_mode: None,
worktree_mode: None,
head_oid: Some(head_entry.oid),
index_oid: None,
submodule: None,
});
}
entries.sort_by(|left, right| {
status_sort_category(left)
.cmp(&status_sort_category(right))
.then_with(|| left.path.cmp(&right.path))
});
Ok(entries)
}
fn tracked_only_precheck_index(precheck: TrackedOnlyPrecheck) -> usize {
match precheck {
TrackedOnlyPrecheck::Deleted(idx) | TrackedOnlyPrecheck::Slow(idx) => idx,
}
}
fn stage0_index_entry_count<E>(entries: &[E], mut stage: impl FnMut(&E) -> Stage) -> usize {
entries
.iter()
.filter(|entry| stage(entry) == Stage::Normal)
.count()
}
fn stage0_index_chunk_ranges<E>(
entries: &[E],
chunk_size: usize,
mut stage: impl FnMut(&E) -> Stage,
) -> Vec<std::ops::Range<usize>> {
debug_assert!(chunk_size > 0);
let mut ranges = Vec::new();
let mut start = None;
let mut end = 0usize;
let mut normals_in_chunk = 0usize;
for (idx, entry) in entries.iter().enumerate() {
if stage(entry) != Stage::Normal {
continue;
}
if start.is_none() {
start = Some(idx);
}
end = idx + 1;
normals_in_chunk += 1;
if normals_in_chunk == chunk_size {
ranges.push(start.expect("chunk start must exist")..end);
start = None;
normals_in_chunk = 0;
}
}
if let Some(start) = start {
ranges.push(start..end);
}
ranges
}
fn tracked_only_non_clean_prechecks_parallel(
worktree_root: &Path,
index: &Index,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
) -> Result<Vec<TrackedOnlyPrecheck>> {
let normal_count = stage0_index_entry_count(&index.entries, IndexEntry::stage);
if normal_count == 0 {
return Ok(Vec::new());
}
let max_workers = std::thread::available_parallelism()
.map(|count| count.get())
.unwrap_or(1)
.min(4);
let worker_count = max_workers.min(normal_count.div_ceil(512)).max(1);
if worker_count == 1 {
let mut prechecks = Vec::new();
let mut absolute = PathBuf::new();
for (idx, entry) in index.entries.iter().enumerate() {
if entry.stage() != Stage::Normal {
continue;
}
match tracked_only_stat_precheck(
worktree_root,
entry,
stat_cache,
sparse_checkout_active,
&mut absolute,
)? {
TrackedOnlyPrecheckOutcome::Clean => {}
TrackedOnlyPrecheckOutcome::Deleted => {
prechecks.push(TrackedOnlyPrecheck::Deleted(idx));
}
TrackedOnlyPrecheckOutcome::Slow => {
prechecks.push(TrackedOnlyPrecheck::Slow(idx));
}
}
}
return Ok(prechecks);
}
let chunk_size = normal_count.div_ceil(worker_count);
let chunk_ranges = stage0_index_chunk_ranges(&index.entries, chunk_size, IndexEntry::stage);
let mut prechecks = std::thread::scope(|scope| -> Result<Vec<TrackedOnlyPrecheck>> {
let mut handles = Vec::new();
for range in chunk_ranges {
handles.push(spawn_status_worker(
scope,
"status-precheck",
move || -> Result<Vec<TrackedOnlyPrecheck>> {
let mut prechecks = Vec::new();
let mut absolute = PathBuf::new();
for idx in range {
let entry = &index.entries[idx];
if entry.stage() != Stage::Normal {
continue;
}
match tracked_only_stat_precheck(
worktree_root,
entry,
stat_cache,
sparse_checkout_active,
&mut absolute,
)? {
TrackedOnlyPrecheckOutcome::Clean => {}
TrackedOnlyPrecheckOutcome::Deleted => {
prechecks.push(TrackedOnlyPrecheck::Deleted(idx));
}
TrackedOnlyPrecheckOutcome::Slow => {
prechecks.push(TrackedOnlyPrecheck::Slow(idx));
}
}
}
Ok(prechecks)
},
)?);
}
let mut prechecks = Vec::new();
for handle in handles {
let mut chunk = handle
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
prechecks.append(&mut chunk);
}
Ok(prechecks)
})?;
prechecks.sort_by_key(|precheck| tracked_only_precheck_index(*precheck));
Ok(prechecks)
}
fn tracked_only_borrowed_non_clean_prechecks_parallel(
worktree_root: &Path,
index: &BorrowedIndex<'_>,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
) -> Result<Vec<TrackedOnlyPrecheck>> {
let normal_count = stage0_index_entry_count(&index.entries, IndexEntryRef::stage);
if normal_count == 0 {
return Ok(Vec::new());
}
let max_workers = std::thread::available_parallelism()
.map(|count| count.get())
.unwrap_or(1)
.min(4);
let worker_count = max_workers.min(normal_count.div_ceil(512)).max(1);
if worker_count == 1 {
let mut prechecks = Vec::new();
let mut absolute = PathBuf::new();
for (idx, entry) in index.entries.iter().enumerate() {
if entry.stage() != Stage::Normal {
continue;
}
match tracked_only_borrowed_stat_precheck(
worktree_root,
entry,
stat_cache,
sparse_checkout_active,
&mut absolute,
)? {
TrackedOnlyPrecheckOutcome::Clean => {}
TrackedOnlyPrecheckOutcome::Deleted => {
prechecks.push(TrackedOnlyPrecheck::Deleted(idx));
}
TrackedOnlyPrecheckOutcome::Slow => {
prechecks.push(TrackedOnlyPrecheck::Slow(idx));
}
}
}
return Ok(prechecks);
}
let chunk_size = normal_count.div_ceil(worker_count);
let chunk_ranges = stage0_index_chunk_ranges(&index.entries, chunk_size, IndexEntryRef::stage);
let mut prechecks = std::thread::scope(|scope| -> Result<Vec<TrackedOnlyPrecheck>> {
let mut handles = Vec::new();
for range in chunk_ranges {
handles.push(spawn_status_worker(
scope,
"status-precheck",
move || -> Result<Vec<TrackedOnlyPrecheck>> {
let mut prechecks = Vec::new();
let mut absolute = PathBuf::new();
for idx in range {
let entry = &index.entries[idx];
if entry.stage() != Stage::Normal {
continue;
}
match tracked_only_borrowed_stat_precheck(
worktree_root,
entry,
stat_cache,
sparse_checkout_active,
&mut absolute,
)? {
TrackedOnlyPrecheckOutcome::Clean => {}
TrackedOnlyPrecheckOutcome::Deleted => {
prechecks.push(TrackedOnlyPrecheck::Deleted(idx));
}
TrackedOnlyPrecheckOutcome::Slow => {
prechecks.push(TrackedOnlyPrecheck::Slow(idx));
}
}
}
Ok(prechecks)
},
)?);
}
let mut prechecks = Vec::new();
for handle in handles {
let mut chunk = handle
.join()
.map_err(|_| GitError::Command("status worker panicked".into()))??;
prechecks.append(&mut chunk);
}
Ok(prechecks)
})?;
prechecks.sort_by_key(|precheck| tracked_only_precheck_index(*precheck));
Ok(prechecks)
}
fn tracked_only_stat_precheck(
worktree_root: &Path,
index_entry: &IndexEntry,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
absolute: &mut PathBuf,
) -> Result<TrackedOnlyPrecheckOutcome> {
if sley_index::is_gitlink(index_entry.mode) {
return Ok(TrackedOnlyPrecheckOutcome::Slow);
}
let git_path = index_entry.path.as_bytes();
set_worktree_path_from_repo_path(worktree_root, git_path, absolute)?;
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
if sparse_checkout_active && index_entry.is_skip_worktree() {
return Ok(TrackedOnlyPrecheckOutcome::Clean);
}
return Ok(TrackedOnlyPrecheckOutcome::Deleted);
}
Err(err) => return Err(err.into()),
};
let file_type = metadata.file_type();
if file_type.is_dir() || !(file_type.is_file() || file_type.is_symlink()) {
return Ok(TrackedOnlyPrecheckOutcome::Slow);
}
if stat_cache
.reuse_index_entry(index_entry, &metadata)
.is_some()
{
Ok(TrackedOnlyPrecheckOutcome::Clean)
} else {
Ok(TrackedOnlyPrecheckOutcome::Slow)
}
}
fn tracked_only_borrowed_stat_precheck(
worktree_root: &Path,
index_entry: &IndexEntryRef<'_>,
stat_cache: &IndexStatCache,
sparse_checkout_active: bool,
absolute: &mut PathBuf,
) -> Result<TrackedOnlyPrecheckOutcome> {
if sley_index::is_gitlink(index_entry.mode) {
return Ok(TrackedOnlyPrecheckOutcome::Slow);
}
set_worktree_path_from_repo_path(worktree_root, index_entry.path, absolute)?;
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
if sparse_checkout_active && index_entry.is_skip_worktree() {
return Ok(TrackedOnlyPrecheckOutcome::Clean);
}
return Ok(TrackedOnlyPrecheckOutcome::Deleted);
}
Err(err) => return Err(err.into()),
};
let file_type = metadata.file_type();
if file_type.is_dir() || !(file_type.is_file() || file_type.is_symlink()) {
return Ok(TrackedOnlyPrecheckOutcome::Slow);
}
if stat_cache
.reuse_index_entry_ref(index_entry, &metadata)
.is_some()
{
Ok(TrackedOnlyPrecheckOutcome::Clean)
} else {
Ok(TrackedOnlyPrecheckOutcome::Slow)
}
}
fn set_worktree_path_from_repo_path(
worktree_root: &Path,
git_path: &[u8],
out: &mut PathBuf,
) -> Result<()> {
out.clear();
out.push(worktree_root);
push_repo_path(out, git_path)
}
#[cfg(unix)]
fn push_repo_path(out: &mut PathBuf, path: &[u8]) -> Result<()> {
use std::os::unix::ffi::OsStrExt;
out.push(Path::new(std::ffi::OsStr::from_bytes(path)));
Ok(())
}
#[cfg(not(unix))]
fn push_repo_path(out: &mut PathBuf, path: &[u8]) -> Result<()> {
let path = std::str::from_utf8(path)
.map_err(|_| GitError::InvalidPath("index path is not utf8".into()))?;
for component in path.split('/') {
out.push(component);
}
Ok(())
}
fn tracked_only_submodule_status(
worktree_root: &Path,
path: &[u8],
index_entry: &TrackedEntry,
worktree_entry: Option<&TrackedEntry>,
_untracked_mode: StatusUntrackedMode,
) -> Result<Option<SubmoduleStatus>> {
let Some(worktree_entry) = worktree_entry else {
return Ok(None);
};
if !sley_index::is_gitlink(index_entry.mode) || !sley_index::is_gitlink(worktree_entry.mode) {
return Ok(None);
}
let absolute = worktree_root.join(repo_path_to_os_path(path)?);
let dirt = if absolute.is_dir() {
submodule_dirt(&absolute)
} else {
0
};
Ok(Some(SubmoduleStatus {
new_commits: index_entry.oid != worktree_entry.oid,
modified_content: dirt & DIRTY_SUBMODULE_MODIFIED != 0,
untracked_content: dirt & DIRTY_SUBMODULE_UNTRACKED != 0,
}))
}
fn status_sort_category(entry: &ShortStatusEntry) -> u8 {
match (entry.index, entry.worktree) {
(b'?', b'?') => 1,
(b'!', b'!') => 2,
_ => 0,
}
}
pub fn untracked_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<Vec<Vec<u8>>> {
untracked_paths_with_options(
worktree_root,
git_dir,
format,
UntrackedPathOptions::default(),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UntrackedPathspecFilter {
pub path: Vec<u8>,
pub recursive: bool,
pub is_glob: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UntrackedPathOptions {
pub directory: bool,
pub no_empty_directory: bool,
pub preserve_ignored_directories: bool,
pub exclude_standard: bool,
pub ignored_only: bool,
pub exclude_patterns: Vec<Vec<u8>>,
pub exclude_per_directory: Vec<String>,
pub pathspecs: Vec<UntrackedPathspecFilter>,
}
pub use sley_pathspec::{
PathspecMatchMagic, WM_CASEFOLD, WM_PATHNAME, pathspec_is_glob, pathspec_item_matches,
wildmatch,
};
pub fn untracked_pathspec_matches(spec: &UntrackedPathspecFilter, path: &[u8]) -> bool {
if spec.path.is_empty() {
return true;
}
let path_no_slash = path.strip_suffix(b"/").unwrap_or(path);
if path == spec.path.as_slice() || path_no_slash == spec.path.as_slice() {
return true;
}
if spec.recursive
&& let Some(rest) = path
.strip_prefix(spec.path.as_slice())
.and_then(|rest| rest.strip_prefix(b"/"))
&& !rest.is_empty()
{
return true;
}
if spec.is_glob {
return untracked_wildmatch(&spec.path, path)
|| untracked_wildmatch(&spec.path, path_no_slash);
}
false
}
pub fn untracked_pathspec_needs_descent(parent: &[u8], specs: &[UntrackedPathspecFilter]) -> bool {
if specs.is_empty() {
return false;
}
let parent_prefix = if parent.is_empty() {
Vec::new()
} else {
let mut prefix = parent.to_vec();
prefix.push(b'/');
prefix
};
for spec in specs {
if !parent.is_empty()
&& spec.path.starts_with(&parent_prefix)
&& spec.path.as_slice() != parent
{
return true;
}
if spec.is_glob && glob_pathspec_may_match_under(&spec.path, parent) {
return true;
}
if spec.recursive
&& !parent.is_empty()
&& parent.starts_with(spec.path.as_slice())
&& parent != spec.path.as_slice()
{
return true;
}
}
false
}
fn untracked_pathspec_selects_directory(
specs: &[UntrackedPathspecFilter],
git_path: &[u8],
) -> bool {
specs
.iter()
.any(|spec| untracked_pathspec_matches(spec, git_path))
}
fn glob_pathspec_may_match_under(pattern: &[u8], dir: &[u8]) -> bool {
let literal_prefix = literal_prefix_before_glob(pattern);
if literal_prefix.is_empty() {
return true;
}
if dir.is_empty() {
return true;
}
let mut dir_prefix = dir.to_vec();
dir_prefix.push(b'/');
if literal_prefix.starts_with(&dir_prefix) {
return true;
}
if dir_prefix.starts_with(&literal_prefix) {
return true;
}
literal_prefix
.strip_suffix(b"/")
.is_some_and(|prefix| prefix == dir)
}
fn literal_prefix_before_glob(pattern: &[u8]) -> Vec<u8> {
let mut prefix = Vec::new();
for &byte in pattern {
if matches!(byte, b'*' | b'?' | b'[') {
break;
}
prefix.push(byte);
}
prefix
}
fn insert_untracked_directory(paths: &mut BTreeSet<Vec<u8>>, git_path: &[u8]) {
let mut directory = git_path.to_vec();
if directory.last() != Some(&b'/') {
directory.push(b'/');
}
paths.insert(directory);
}
fn untracked_wildmatch(pattern: &[u8], text: &[u8]) -> bool {
wildmatch(pattern, text, 0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IgnoreMatch {
pub source: Vec<u8>,
pub line_number: usize,
pub pattern: Vec<u8>,
pub ignored: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeState {
Set,
Unset,
Value(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttributeCheck {
pub attribute: Vec<u8>,
pub state: Option<AttributeState>,
}
pub fn untracked_paths_with_options(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
options: UntrackedPathOptions,
) -> Result<Vec<Vec<u8>>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let (index, stat_cache, _) = read_index_entries_with_stat_cache(git_dir, format, &db)?;
let all_index_paths = read_all_index_paths(git_dir, format)?;
let ignores = IgnoreMatcher::from_sources(
worktree_root,
options.exclude_standard,
&options.exclude_patterns,
&options.exclude_per_directory,
)?;
if options.ignored_only {
return ignored_untracked_paths(
worktree_root,
git_dir,
&index,
&ignores,
options.directory,
);
}
if options.directory {
let mut paths = BTreeSet::new();
collect_untracked_directory_paths(
worktree_root,
git_dir,
worktree_root,
&index,
&ignores,
&options,
&mut paths,
)?;
return Ok(paths.into_iter().collect());
}
let worktree = worktree_entries_with_stat_cache(
worktree_root,
git_dir,
format,
Some(&stat_cache),
None,
None,
)?;
Ok(ls_files_untracked_paths_from_worktree(
&worktree,
&index,
&all_index_paths,
&ignores,
))
}
fn ls_files_untracked_paths_from_worktree(
worktree: &BTreeMap<Vec<u8>, TrackedEntry>,
index: &BTreeMap<Vec<u8>, TrackedEntry>,
all_index_paths: &BTreeSet<Vec<u8>>,
ignores: &IgnoreMatcher,
) -> Vec<Vec<u8>> {
let mut paths = BTreeSet::new();
for (path, entry) in worktree {
if index.contains_key(path)
|| all_index_paths.contains(path)
|| ignores.is_ignored(path, false)
{
continue;
}
if entry.mode == 0o040000 && entry.oid.is_null() {
insert_untracked_directory(&mut paths, path);
continue;
}
paths.insert(path.clone());
}
paths.into_iter().collect()
}
pub fn path_matches_standard_ignore(
worktree_root: impl AsRef<Path>,
path: &[u8],
is_dir: bool,
) -> Result<bool> {
path_matches_ignore(worktree_root, path, is_dir, true, &[])
}
pub fn standard_ignore_match(
worktree_root: impl AsRef<Path>,
path: &[u8],
is_dir: bool,
) -> Result<Option<IgnoreMatch>> {
let ignores = IgnoreMatcher::from_worktree_root(worktree_root.as_ref())?;
Ok(ignores.match_for(path, is_dir).map(IgnorePattern::to_match))
}
pub fn standard_attributes_for_path(
worktree_root: impl AsRef<Path>,
path: &[u8],
requested: &[Vec<u8>],
all: bool,
) -> Result<Vec<AttributeCheck>> {
let matcher = AttributeMatcher::from_worktree_root(worktree_root.as_ref())?;
Ok(matcher.attributes_for_path(path, requested, all))
}
pub struct StandardAttributeMatcher {
matcher: AttributeMatcher,
}
impl StandardAttributeMatcher {
pub fn from_worktree_root(worktree_root: impl AsRef<Path>) -> Result<Self> {
Ok(Self {
matcher: AttributeMatcher::from_worktree_root(worktree_root.as_ref())?,
})
}
pub fn attributes_for_path(
&self,
path: &[u8],
requested: &[Vec<u8>],
all: bool,
) -> Vec<AttributeCheck> {
self.matcher.attributes_for_path(path, requested, all)
}
}
pub fn standard_attributes_for_path_in_repo(
attr_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
path: &[u8],
requested: &[Vec<u8>],
all: bool,
include_worktree_attributes: bool,
ignore_case: bool,
) -> Result<Vec<AttributeCheck>> {
let attr_root = attr_root.as_ref();
let git_dir = git_dir.as_ref();
let mut matcher = AttributeMatcher::default();
matcher.configure_case_sensitivity(git_dir);
matcher.ignore_case = ignore_case;
if !matcher.read_configured_attributes(attr_root, git_dir) {
matcher.read_default_global_attributes();
}
if include_worktree_attributes {
collect_attribute_patterns(attr_root, attr_root, &mut matcher)?;
}
read_attribute_patterns(
git_dir.join("info").join("attributes"),
&mut matcher,
&[],
b"info/attributes",
false,
);
Ok(matcher.attributes_for_path(path, requested, all))
}
pub fn standard_attributes_for_path_from_tree(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
path: &[u8],
requested: &[Vec<u8>],
all: bool,
) -> Result<Vec<AttributeCheck>> {
let mut matcher = AttributeMatcher::default();
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
matcher.configure_case_sensitivity(git_dir);
if !matcher.read_configured_attributes(worktree_root, git_dir) {
matcher.read_default_global_attributes();
}
collect_attribute_patterns_from_tree(db, format, tree_oid, Vec::new(), &mut matcher)?;
read_attribute_patterns(
git_dir.join("info").join("attributes"),
&mut matcher,
&[],
b"info/attributes",
false,
);
Ok(matcher.attributes_for_path(path, requested, all))
}
pub fn standard_attributes_for_path_from_index(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
path: &[u8],
requested: &[Vec<u8>],
all: bool,
) -> Result<Vec<AttributeCheck>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let mut matcher = AttributeMatcher::default();
matcher.configure_case_sensitivity(git_dir);
if !matcher.read_configured_attributes(worktree_root, git_dir) {
matcher.read_default_global_attributes();
}
let db = FileObjectDatabase::from_git_dir(git_dir, format);
collect_attribute_patterns_from_index(git_dir, format, &db, &mut matcher)?;
read_attribute_patterns(
git_dir.join("info").join("attributes"),
&mut matcher,
&[],
b"info/attributes",
false,
);
Ok(matcher.attributes_for_path(path, requested, all))
}
pub fn path_matches_ignore(
worktree_root: impl AsRef<Path>,
path: &[u8],
is_dir: bool,
exclude_standard: bool,
exclude_patterns: &[Vec<u8>],
) -> Result<bool> {
path_matches_ignore_with_per_directory(
worktree_root,
path,
is_dir,
exclude_standard,
exclude_patterns,
&[],
)
}
pub fn path_matches_ignore_with_per_directory(
worktree_root: impl AsRef<Path>,
path: &[u8],
is_dir: bool,
exclude_standard: bool,
exclude_patterns: &[Vec<u8>],
exclude_per_directory: &[String],
) -> Result<bool> {
let ignores = IgnoreMatcher::from_sources(
worktree_root.as_ref(),
exclude_standard,
exclude_patterns,
exclude_per_directory,
)?;
Ok(ignores.is_ignored(path, is_dir))
}
pub fn ignored_index_entries<'a>(
worktree_root: impl AsRef<Path>,
entries: &'a [IndexEntry],
exclude_standard: bool,
exclude_patterns: &[Vec<u8>],
exclude_per_directory: &[String],
) -> Result<Vec<&'a IndexEntry>> {
let ignores = IgnoreMatcher::from_sources(
worktree_root.as_ref(),
exclude_standard,
exclude_patterns,
exclude_per_directory,
)?;
Ok(entries
.iter()
.filter(|entry| ignores.is_ignored(entry.path.as_bytes(), false))
.collect())
}
fn collect_untracked_directory_paths(
root: &Path,
git_dir: &Path,
dir: &Path,
index: &BTreeMap<Vec<u8>, TrackedEntry>,
ignores: &IgnoreMatcher,
options: &UntrackedPathOptions,
paths: &mut BTreeSet<Vec<u8>>,
) -> Result<()> {
if is_same_path(dir, git_dir) {
return Ok(());
}
let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if is_dot_git_entry(&path) {
continue;
}
if is_embedded_git_internals(root, &path) {
continue;
}
if is_same_path(&path, git_dir) {
continue;
}
let metadata = entry.metadata()?;
let relative = path.strip_prefix(root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if index
.get(&git_path)
.is_some_and(|entry| sley_index::is_gitlink(entry.mode))
{
continue;
}
if ignores.is_ignored(&git_path, metadata.is_dir()) {
continue;
}
if metadata.is_dir() {
if is_nested_repository_boundary(&path, git_dir) {
insert_untracked_directory(paths, &git_path);
continue;
}
let has_tracked_below = index_has_path_under(index, &git_path);
let needs_descent = untracked_pathspec_needs_descent(&git_path, &options.pathspecs);
if has_tracked_below {
collect_untracked_directory_paths(
root, git_dir, &path, index, ignores, options, paths,
)?;
} else if active_repository_worktree_dir(&path, git_dir) {
insert_untracked_directory(paths, &git_path);
} else if needs_descent {
if untracked_pathspec_selects_directory(&options.pathspecs, &git_path) {
insert_untracked_directory(paths, &git_path);
continue;
}
collect_untracked_directory_paths(
root, git_dir, &path, index, ignores, options, paths,
)?;
} else if options.preserve_ignored_directories
&& directory_has_ignored(&path, root, git_dir, ignores)?
{
collect_untracked_directory_paths(
root, git_dir, &path, index, ignores, options, paths,
)?;
} else if !options.no_empty_directory
|| directory_has_file(&path, root, git_dir, ignores)?
{
insert_untracked_directory(paths, &git_path);
}
} else if !index.contains_key(&git_path)
&& (metadata.is_file() || metadata.file_type().is_symlink())
&& (options.pathspecs.is_empty()
|| options
.pathspecs
.iter()
.any(|spec| untracked_pathspec_matches(spec, &git_path)))
{
paths.insert(git_path);
}
}
Ok(())
}
fn index_has_path_under(index: &BTreeMap<Vec<u8>, TrackedEntry>, directory: &[u8]) -> bool {
let mut prefix = directory.to_vec();
prefix.push(b'/');
index
.range::<[u8], _>((
std::ops::Bound::Included(prefix.as_slice()),
std::ops::Bound::Unbounded,
))
.next()
.is_some_and(|(path, _)| path.starts_with(&prefix))
}
fn normal_untracked_paths_from_worktree(
worktree: &BTreeMap<Vec<u8>, TrackedEntry>,
index: &BTreeMap<Vec<u8>, TrackedEntry>,
ignores: &IgnoreMatcher,
) -> Vec<Vec<u8>> {
let mut paths = BTreeSet::new();
for (path, entry) in worktree {
if index.contains_key(path) || path_or_parent_is_ignored(ignores, path, false) {
continue;
}
if entry.mode == 0o040000 && entry.oid.is_null() {
insert_untracked_directory(&mut paths, path);
continue;
}
paths.insert(untracked_normal_rollup_path(path, index, ignores));
}
paths.into_iter().collect()
}
fn path_or_parent_is_ignored(ignores: &IgnoreMatcher, path: &[u8], is_dir: bool) -> bool {
if ignores.is_ignored(path, is_dir) {
return true;
}
for (index, byte) in path.iter().enumerate() {
if *byte == b'/' && index > 0 && ignores.is_ignored(&path[..index], true) {
return true;
}
}
false
}
fn status_untracked_paths_from_index(
root: &Path,
git_dir: &Path,
index: &Index,
stat_cache: &IndexStatCache,
ignores: &mut IgnoreMatcher,
untracked_mode: StatusUntrackedMode,
profile: Option<&mut StatusProfileCounters>,
) -> Result<Vec<Vec<u8>>> {
if matches!(untracked_mode, StatusUntrackedMode::None) {
return Ok(Vec::new());
}
let mut paths = Vec::new();
let tracked_dirs = stage0_tracked_directories(index);
let tracked = IndexStatusLookup {
stat_cache,
tracked_dirs: &tracked_dirs,
};
let mut context = StatusUntrackedWalk {
git_dir,
tracked: &tracked,
ignores,
untracked_mode,
profile,
};
collect_status_untracked_paths(&mut context, root, &[], &mut paths)?;
paths.sort();
paths.dedup();
Ok(paths)
}
fn status_untracked_paths_from_borrowed_index(
root: &Path,
git_dir: &Path,
index: &BorrowedIndex<'_>,
ignores: &mut IgnoreMatcher,
untracked_mode: StatusUntrackedMode,
profile: Option<&mut StatusProfileCounters>,
) -> Result<Vec<Vec<u8>>> {
if matches!(untracked_mode, StatusUntrackedMode::None) {
return Ok(Vec::new());
}
let mut paths = Vec::new();
let tracked = BorrowedIndexLookup::new(&index.entries);
let mut context = StatusUntrackedWalk {
git_dir,
tracked: &tracked,
ignores,
untracked_mode,
profile,
};
collect_status_untracked_paths(&mut context, root, &[], &mut paths)?;
paths.sort();
paths.dedup();
Ok(paths)
}
fn stream_status_untracked_paths_from_borrowed_index<F>(
root: &Path,
git_dir: &Path,
index: &BorrowedIndex<'_>,
ignores: &mut IgnoreMatcher,
untracked_mode: StatusUntrackedMode,
profile: Option<&mut StatusProfileCounters>,
mut emit: F,
) -> Result<()>
where
F: for<'a> FnMut(&'a [u8]) -> Result<StreamControl>,
{
if matches!(untracked_mode, StatusUntrackedMode::None) {
return Ok(());
}
let tracked = BorrowedIndexLookup::new(&index.entries);
let mut context = StatusUntrackedWalk {
git_dir,
tracked: &tracked,
ignores,
untracked_mode,
profile,
};
stream_status_untracked_paths(&mut context, root, &[], &mut emit).map(|_| ())
}
fn status_untracked_count_from_borrowed_index(
root: &Path,
git_dir: &Path,
index: &BorrowedIndex<'_>,
ignores: &mut IgnoreMatcher,
untracked_mode: StatusUntrackedMode,
profile: Option<&mut StatusProfileCounters>,
) -> Result<usize> {
if matches!(untracked_mode, StatusUntrackedMode::None) {
return Ok(0);
}
let tracked = BorrowedIndexLookup::new(&index.entries);
let mut context = StatusUntrackedWalk {
git_dir,
tracked: &tracked,
ignores,
untracked_mode,
profile,
};
let mut count = 0usize;
count_status_untracked_paths(&mut context, root, &[], &mut count)?;
Ok(count)
}
trait StatusTrackedLookup {
fn tracked_kind(&self, git_path: &[u8]) -> Option<StatusTrackedKind>;
fn tracked_directory_kind(&self, git_path: &[u8]) -> Option<StatusTrackedDirectoryKind>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusTrackedKind {
File,
Gitlink,
SkipWorktree,
}
impl StatusTrackedKind {
fn from_mode_and_skip(mode: u32, skip_worktree: bool) -> Self {
if sley_index::is_gitlink(mode) {
Self::Gitlink
} else if skip_worktree {
Self::SkipWorktree
} else {
Self::File
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusTrackedDirectoryKind {
ContainsTracked,
TrackedExcluded,
}
struct IndexStatusLookup<'a> {
stat_cache: &'a IndexStatCache,
tracked_dirs: &'a HashSet<&'a [u8]>,
}
impl StatusTrackedLookup for IndexStatusLookup<'_> {
fn tracked_kind(&self, git_path: &[u8]) -> Option<StatusTrackedKind> {
self.stat_cache.entries.get(git_path).map(|entry| {
StatusTrackedKind::from_mode_and_skip(entry.mode, entry.is_skip_worktree())
})
}
fn tracked_directory_kind(&self, git_path: &[u8]) -> Option<StatusTrackedDirectoryKind> {
self.tracked_dirs
.contains(git_path)
.then_some(StatusTrackedDirectoryKind::ContainsTracked)
}
}
struct BorrowedIndexLookup<'a> {
entries: &'a [IndexEntryRef<'a>],
exact_cursor: Cell<usize>,
directory_prefix: RefCell<Vec<u8>>,
}
impl<'a> BorrowedIndexLookup<'a> {
fn new(entries: &'a [IndexEntryRef<'a>]) -> Self {
Self {
entries,
exact_cursor: Cell::new(0),
directory_prefix: RefCell::new(Vec::new()),
}
}
}
impl StatusTrackedLookup for BorrowedIndexLookup<'_> {
fn tracked_kind(&self, git_path: &[u8]) -> Option<StatusTrackedKind> {
let mut start = self.exact_cursor.get().min(self.entries.len());
if start == self.entries.len() || self.entries[start].path > git_path {
start = self.entries.partition_point(|entry| entry.path < git_path);
} else {
while start < self.entries.len() && self.entries[start].path < git_path {
start += 1;
}
}
self.exact_cursor.set(start);
self.entries[start..]
.iter()
.take_while(|entry| entry.path == git_path)
.find(|entry| entry.stage() == Stage::Normal)
.map(|entry| {
StatusTrackedKind::from_mode_and_skip(entry.mode, entry.is_skip_worktree())
})
}
fn tracked_directory_kind(&self, git_path: &[u8]) -> Option<StatusTrackedDirectoryKind> {
let mut prefix_buf = self.directory_prefix.borrow_mut();
prefix_buf.clear();
prefix_buf.extend_from_slice(git_path);
prefix_buf.push(b'/');
let prefix = prefix_buf.as_slice();
let start = self.entries.partition_point(|entry| entry.path < prefix);
let mut saw_normal = false;
for entry in self.entries[start..]
.iter()
.take_while(|entry| entry.path.starts_with(prefix))
{
if entry.stage() != Stage::Normal {
continue;
}
saw_normal = true;
if !entry.is_skip_worktree() {
return Some(StatusTrackedDirectoryKind::ContainsTracked);
}
}
saw_normal.then_some(StatusTrackedDirectoryKind::TrackedExcluded)
}
}
struct StatusUntrackedWalk<'a, T: StatusTrackedLookup + ?Sized> {
git_dir: &'a Path,
tracked: &'a T,
ignores: &'a mut IgnoreMatcher,
untracked_mode: StatusUntrackedMode,
profile: Option<&'a mut StatusProfileCounters>,
}
fn collect_status_untracked_paths<T: StatusTrackedLookup + ?Sized>(
context: &mut StatusUntrackedWalk<'_, T>,
dir: &Path,
dir_git_path: &[u8],
paths: &mut Vec<Vec<u8>>,
) -> Result<()> {
if is_same_path(dir, context.git_dir) {
return Ok(());
}
let ignore_len = context.ignores.patterns.len();
let mut entries = read_dir_entries_with_ignore_patterns(
dir,
dir_git_path,
context.ignores,
context.profile.as_deref_mut(),
)?;
entries.sort_by_key(|entry| entry.file_name());
let result = (|| -> Result<()> {
let mut git_path = dir_git_path.to_vec();
for entry in entries {
let file_name = entry.file_name();
if file_name == std::ffi::OsStr::new(".git") {
continue;
}
let path_len = git_path_push_component(&mut git_path, &file_name);
let entry_result = (|| -> Result<()> {
if let Some(tracked_kind) = context.tracked.tracked_kind(&git_path) {
if let Some(profile) = context.profile.as_deref_mut() {
profile.tracked_exact_hits += 1;
}
if !matches!(context.untracked_mode, StatusUntrackedMode::All)
|| tracked_kind == StatusTrackedKind::Gitlink
{
return Ok(());
}
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
if file_type.is_dir() {
let path = entry.path();
if !is_same_path(&path, context.git_dir) {
collect_status_untracked_paths(context, &path, &git_path, paths)?;
}
}
return Ok(());
}
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
let is_dir = file_type.is_dir();
if file_type.is_file() || file_type.is_symlink() {
if !context.ignores.is_ignored_profiled(
&git_path,
false,
context.profile.as_deref_mut(),
) {
paths.push(git_path.clone());
}
return Ok(());
} else if is_dir {
let path = entry.path();
if context.ignores.is_ignored_profiled(
&git_path,
true,
context.profile.as_deref_mut(),
) {
return Ok(());
}
if is_same_path(&path, context.git_dir) {
return Ok(());
}
let tracked_directory = context.tracked.tracked_directory_kind(&git_path);
if let Some(directory_kind) = tracked_directory {
if let Some(profile) = context.profile.as_deref_mut() {
profile.tracked_dir_prefix_hits += 1;
if directory_kind == StatusTrackedDirectoryKind::TrackedExcluded {
profile.tracked_skip_worktree_prefix_hits += 1;
}
}
}
match context.untracked_mode {
StatusUntrackedMode::All => {
if tracked_directory.is_none()
&& is_nested_repository_boundary(&path, context.git_dir)
{
push_untracked_directory(paths, &git_path);
} else {
collect_status_untracked_paths(context, &path, &git_path, paths)?;
}
}
StatusUntrackedMode::Normal => {
if tracked_directory.is_some() {
collect_status_untracked_paths(context, &path, &git_path, paths)?;
} else if is_nested_repository_boundary(&path, context.git_dir) {
push_untracked_directory(paths, &git_path);
} else if status_untracked_directory_has_file(
context, &path, &git_path,
)? {
push_untracked_directory(paths, &git_path);
}
}
StatusUntrackedMode::None => {}
}
}
Ok(())
})();
git_path.truncate(path_len);
entry_result?;
}
Ok(())
})();
context.ignores.truncate(ignore_len);
result
}
fn stream_status_untracked_paths<T, F>(
context: &mut StatusUntrackedWalk<'_, T>,
dir: &Path,
dir_git_path: &[u8],
emit: &mut F,
) -> Result<StreamControl>
where
T: StatusTrackedLookup + ?Sized,
F: for<'a> FnMut(&'a [u8]) -> Result<StreamControl>,
{
if is_same_path(dir, context.git_dir) {
return Ok(StreamControl::Continue);
}
let ignore_len = context.ignores.patterns.len();
let mut entries = read_dir_entries_with_ignore_patterns(
dir,
dir_git_path,
context.ignores,
context.profile.as_deref_mut(),
)?;
entries.sort_by_key(|entry| entry.file_name());
let result = (|| -> Result<StreamControl> {
let mut git_path = dir_git_path.to_vec();
for entry in entries {
let file_name = entry.file_name();
if file_name == std::ffi::OsStr::new(".git") {
continue;
}
let path_len = git_path_push_component(&mut git_path, &file_name);
let entry_result = (|| -> Result<StreamControl> {
if let Some(tracked_kind) = context.tracked.tracked_kind(&git_path) {
if let Some(profile) = context.profile.as_deref_mut() {
profile.tracked_exact_hits += 1;
}
if !matches!(context.untracked_mode, StatusUntrackedMode::All)
|| tracked_kind == StatusTrackedKind::Gitlink
{
return Ok(StreamControl::Continue);
}
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
if file_type.is_dir() {
let path = entry.path();
if !is_same_path(&path, context.git_dir) {
if stream_status_untracked_paths(context, &path, &git_path, emit)?
.is_stop()
{
return Ok(StreamControl::Stop);
}
}
}
return Ok(StreamControl::Continue);
}
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
let is_dir = file_type.is_dir();
if file_type.is_file() || file_type.is_symlink() {
if !context.ignores.is_ignored_profiled(
&git_path,
false,
context.profile.as_deref_mut(),
) {
if emit_status_untracked_path(context, &git_path, emit)?.is_stop() {
return Ok(StreamControl::Stop);
}
}
return Ok(StreamControl::Continue);
} else if is_dir {
if context.ignores.is_ignored_profiled(
&git_path,
true,
context.profile.as_deref_mut(),
) {
return Ok(StreamControl::Continue);
}
let path = entry.path();
if is_same_path(&path, context.git_dir) {
return Ok(StreamControl::Continue);
}
let tracked_directory = context.tracked.tracked_directory_kind(&git_path);
if let Some(directory_kind) = tracked_directory {
if let Some(profile) = context.profile.as_deref_mut() {
profile.tracked_dir_prefix_hits += 1;
if directory_kind == StatusTrackedDirectoryKind::TrackedExcluded {
profile.tracked_skip_worktree_prefix_hits += 1;
}
}
}
match context.untracked_mode {
StatusUntrackedMode::All => {
if tracked_directory.is_none()
&& is_nested_repository_boundary(&path, context.git_dir)
{
let directory_len = git_path.len();
if git_path.last() != Some(&b'/') {
git_path.push(b'/');
}
let control = emit_status_untracked_path(context, &git_path, emit)?;
git_path.truncate(directory_len);
if control.is_stop() {
return Ok(StreamControl::Stop);
}
} else {
if stream_status_untracked_paths(context, &path, &git_path, emit)?
.is_stop()
{
return Ok(StreamControl::Stop);
}
}
}
StatusUntrackedMode::Normal => {
if tracked_directory.is_some() {
if stream_status_untracked_paths(context, &path, &git_path, emit)?
.is_stop()
{
return Ok(StreamControl::Stop);
}
} else if is_nested_repository_boundary(&path, context.git_dir)
|| status_untracked_directory_has_file(context, &path, &git_path)?
{
let directory_len = git_path.len();
if git_path.last() != Some(&b'/') {
git_path.push(b'/');
}
let control = emit_status_untracked_path(context, &git_path, emit)?;
git_path.truncate(directory_len);
if control.is_stop() {
return Ok(StreamControl::Stop);
}
}
}
StatusUntrackedMode::None => {}
}
}
Ok(StreamControl::Continue)
})();
git_path.truncate(path_len);
if entry_result?.is_stop() {
return Ok(StreamControl::Stop);
}
}
Ok(StreamControl::Continue)
})();
context.ignores.truncate(ignore_len);
result
}
fn count_status_untracked_paths<T: StatusTrackedLookup + ?Sized>(
context: &mut StatusUntrackedWalk<'_, T>,
dir: &Path,
dir_git_path: &[u8],
count: &mut usize,
) -> Result<()> {
if is_same_path(dir, context.git_dir) {
return Ok(());
}
let ignore_len = context.ignores.patterns.len();
let mut entries = read_dir_entries_with_ignore_patterns(
dir,
dir_git_path,
context.ignores,
context.profile.as_deref_mut(),
)?;
entries.sort_by_key(|entry| entry.file_name());
let result = (|| -> Result<()> {
let mut git_path = dir_git_path.to_vec();
for entry in entries {
let file_name = entry.file_name();
if file_name == std::ffi::OsStr::new(".git") {
continue;
}
let path_len = git_path_push_component(&mut git_path, &file_name);
let entry_result = (|| -> Result<()> {
if let Some(tracked_kind) = context.tracked.tracked_kind(&git_path) {
if let Some(profile) = context.profile.as_deref_mut() {
profile.tracked_exact_hits += 1;
}
if !matches!(context.untracked_mode, StatusUntrackedMode::All)
|| tracked_kind == StatusTrackedKind::Gitlink
{
return Ok(());
}
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
if file_type.is_dir() {
let path = entry.path();
if !is_same_path(&path, context.git_dir) {
count_status_untracked_paths(context, &path, &git_path, count)?;
}
}
return Ok(());
}
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
let is_dir = file_type.is_dir();
if file_type.is_file() || file_type.is_symlink() {
if !context.ignores.is_ignored_profiled(
&git_path,
false,
context.profile.as_deref_mut(),
) {
*count += 1;
}
return Ok(());
} else if is_dir {
let path = entry.path();
if context.ignores.is_ignored_profiled(
&git_path,
true,
context.profile.as_deref_mut(),
) {
return Ok(());
}
if is_same_path(&path, context.git_dir) {
return Ok(());
}
let tracked_directory = context.tracked.tracked_directory_kind(&git_path);
if let Some(directory_kind) = tracked_directory {
if let Some(profile) = context.profile.as_deref_mut() {
profile.tracked_dir_prefix_hits += 1;
if directory_kind == StatusTrackedDirectoryKind::TrackedExcluded {
profile.tracked_skip_worktree_prefix_hits += 1;
}
}
}
match context.untracked_mode {
StatusUntrackedMode::All => {
if tracked_directory.is_none()
&& is_nested_repository_boundary(&path, context.git_dir)
{
*count += 1;
} else {
count_status_untracked_paths(context, &path, &git_path, count)?;
}
}
StatusUntrackedMode::Normal => {
if tracked_directory.is_some() {
count_status_untracked_paths(context, &path, &git_path, count)?;
} else if is_nested_repository_boundary(&path, context.git_dir)
|| status_untracked_directory_has_file(context, &path, &git_path)?
{
*count += 1;
}
}
StatusUntrackedMode::None => {}
}
}
Ok(())
})();
git_path.truncate(path_len);
entry_result?;
}
Ok(())
})();
context.ignores.truncate(ignore_len);
result
}
fn emit_status_untracked_path<T, F>(
context: &mut StatusUntrackedWalk<'_, T>,
path: &[u8],
emit: &mut F,
) -> Result<StreamControl>
where
T: StatusTrackedLookup + ?Sized,
F: for<'a> FnMut(&'a [u8]) -> Result<StreamControl>,
{
if let Some(profile) = context.profile.as_deref_mut() {
profile.untracked_rows += 1;
}
emit(path)
}
fn stage0_tracked_directories(index: &Index) -> HashSet<&[u8]> {
let mut directories = HashSet::new();
for entry in index
.entries
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
{
let path = entry.path.as_bytes();
for (idx, byte) in path.iter().enumerate() {
if *byte == b'/' && idx > 0 {
directories.insert(&path[..idx]);
}
}
}
directories
}
fn status_untracked_directory_has_file<T: StatusTrackedLookup + ?Sized>(
context: &mut StatusUntrackedWalk<'_, T>,
dir: &Path,
dir_git_path: &[u8],
) -> Result<bool> {
if is_same_path(dir, context.git_dir) {
return Ok(false);
}
let ignore_len = context.ignores.patterns.len();
let mut entries = read_dir_entries_with_ignore_patterns(
dir,
dir_git_path,
context.ignores,
context.profile.as_deref_mut(),
)?;
entries.sort_by_key(|entry| entry.file_name());
let result = (|| -> Result<bool> {
let mut git_path = dir_git_path.to_vec();
for entry in entries {
let file_name = entry.file_name();
if file_name == std::ffi::OsStr::new(".git") {
continue;
}
let path_len = git_path_push_component(&mut git_path, &file_name);
let entry_result = (|| -> Result<Option<bool>> {
if let Some(profile) = context.profile.as_deref_mut() {
profile.file_type_calls += 1;
}
let file_type = entry.file_type()?;
let is_dir = file_type.is_dir();
if context.ignores.is_ignored_profiled(
&git_path,
is_dir,
context.profile.as_deref_mut(),
) {
return Ok(None);
}
if file_type.is_file() || file_type.is_symlink() {
return Ok(Some(true));
}
if is_dir {
let path = entry.path();
if is_same_path(&path, context.git_dir) {
return Ok(None);
}
if is_nested_repository_boundary(&path, context.git_dir) {
return Ok(Some(true));
}
if status_untracked_directory_has_file(context, &path, &git_path)? {
return Ok(Some(true));
}
}
Ok(None)
})();
git_path.truncate(path_len);
if let Some(has_file) = entry_result? {
return Ok(has_file);
}
}
Ok(false)
})();
context.ignores.truncate(ignore_len);
result
}
fn read_dir_entries_with_ignore_patterns(
dir: &Path,
base: &[u8],
matcher: &mut IgnoreMatcher,
mut profile: Option<&mut StatusProfileCounters>,
) -> Result<Vec<fs::DirEntry>> {
let mut entries = Vec::new();
let mut ignore_path = None;
if let Some(profile) = profile.as_deref_mut() {
profile.read_dir_calls += 1;
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
if let Some(profile) = profile.as_deref_mut() {
profile.dir_entries_seen += 1;
}
if entry.file_name() == std::ffi::OsStr::new(".gitignore") {
ignore_path = Some(entry.path());
}
entries.push(entry);
}
if let Some(profile) = profile {
profile.read_dir_entry_vec_cap_bytes +=
(entries.capacity() * std::mem::size_of::<fs::DirEntry>()) as u64;
profile.read_dir_entry_vec_max_len =
profile.read_dir_entry_vec_max_len.max(entries.len() as u64);
profile.read_dir_entry_vec_max_cap = profile
.read_dir_entry_vec_max_cap
.max(entries.capacity() as u64);
}
if let Some(path) = ignore_path {
let mut source = base.to_vec();
if !source.is_empty() {
source.push(b'/');
}
source.extend_from_slice(b".gitignore");
read_per_directory_ignore_patterns_into_matcher(path, matcher, base, &source)?;
}
Ok(entries)
}
fn build_untracked_cache(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index: &Index,
untracked_mode: StatusUntrackedMode,
) -> Result<UntrackedCache> {
let stat_cache = IndexStatCache::from_index(index, &repository_index_path(git_dir));
let tracked_dirs = stage0_tracked_directories(index);
let tracked = IndexStatusLookup {
stat_cache: &stat_cache,
tracked_dirs: &tracked_dirs,
};
let mut ignores = IgnoreMatcher::from_worktree_base(worktree_root)?;
let mut cache = UntrackedCache::new(
format,
untracked_cache_ident(worktree_root),
untracked_cache_dir_flags(untracked_mode),
);
cache.info_exclude = untracked_cache_oid_stat(&git_dir.join("info").join("exclude"), format)?;
cache.excludes_file = UntrackedCacheOidStat::new(format);
cache.root = Some(build_untracked_cache_dir(
worktree_root,
git_dir,
worktree_root,
&[],
b"",
&tracked,
&mut ignores,
untracked_mode,
format,
false,
)?);
Ok(cache)
}
fn emit_untracked_cache_trace(old: Option<&UntrackedCache>, new: &UntrackedCache) {
sley_core::trace2::perf_read_directory_data("path", "");
let dir_count = new
.root
.as_ref()
.map(count_untracked_cache_dirs)
.unwrap_or(0);
let Some(old) = old else {
sley_core::trace2::perf_read_directory_data("node-creation", dir_count.saturating_sub(1));
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
sley_core::trace2::perf_read_directory_data("opendir", dir_count);
return;
};
let Some(old_root) = old.root.as_ref() else {
sley_core::trace2::perf_read_directory_data("node-creation", dir_count.saturating_sub(1));
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
sley_core::trace2::perf_read_directory_data("opendir", dir_count);
return;
};
let Some(new_root) = new.root.as_ref() else {
return;
};
if old.ident != new.ident || old.dir_flags != new.dir_flags {
sley_core::trace2::perf_read_directory_data("node-creation", dir_count.saturating_sub(1));
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
sley_core::trace2::perf_read_directory_data("opendir", dir_count);
return;
}
if old.info_exclude.oid != new.info_exclude.oid
|| old.excludes_file.oid != new.excludes_file.oid
{
sley_core::trace2::perf_read_directory_data("node-creation", 0);
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
sley_core::trace2::perf_read_directory_data("opendir", dir_count);
return;
}
if old_root.exclude_oid != new_root.exclude_oid {
sley_core::trace2::perf_read_directory_data("node-creation", 0);
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 1);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 1);
sley_core::trace2::perf_read_directory_data("opendir", dir_count);
return;
}
let invalid_dir_count = count_invalid_untracked_cache_dirs(old_root);
if invalid_dir_count > 0 {
sley_core::trace2::perf_read_directory_data("node-creation", 0);
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
sley_core::trace2::perf_read_directory_data("opendir", invalid_dir_count);
return;
}
if old_root.stat != new_root.stat {
sley_core::trace2::perf_read_directory_data("node-creation", 0);
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 1);
sley_core::trace2::perf_read_directory_data("opendir", 1);
return;
}
if old.root == new.root {
sley_core::trace2::perf_read_directory_data("node-creation", 0);
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 0);
sley_core::trace2::perf_read_directory_data("opendir", 0);
return;
}
sley_core::trace2::perf_read_directory_data("node-creation", 0);
sley_core::trace2::perf_read_directory_data("gitignore-invalidation", 0);
sley_core::trace2::perf_read_directory_data("directory-invalidation", 1);
sley_core::trace2::perf_read_directory_data("opendir", dir_count);
}
fn count_untracked_cache_dirs(dir: &UntrackedCacheDir) -> usize {
1 + dir
.dirs
.iter()
.map(count_untracked_cache_dirs)
.sum::<usize>()
}
fn count_invalid_untracked_cache_dirs(dir: &UntrackedCacheDir) -> usize {
usize::from(!dir.valid)
+ dir
.dirs
.iter()
.map(count_invalid_untracked_cache_dirs)
.sum::<usize>()
}
#[allow(clippy::too_many_arguments)]
fn build_untracked_cache_dir<T: StatusTrackedLookup + ?Sized>(
worktree_root: &Path,
git_dir: &Path,
dir: &Path,
dir_git_path: &[u8],
name: &[u8],
tracked: &T,
ignores: &mut IgnoreMatcher,
untracked_mode: StatusUntrackedMode,
format: ObjectFormat,
check_only: bool,
) -> Result<UntrackedCacheDir> {
let ignore_len = ignores.patterns.len();
let mut entries = read_dir_entries_with_ignore_patterns(dir, dir_git_path, ignores, None)?;
entries.sort_by_key(|entry| entry.file_name());
let exclude_path = if dir_git_path.is_empty() {
b".gitignore".to_vec()
} else {
let mut path = dir_git_path.to_vec();
path.push(b'/');
path.extend_from_slice(b".gitignore");
path
};
let exclude_oid = if tracked.tracked_kind(&exclude_path).is_some() {
None
} else {
per_directory_ignore_oid(dir, format)?
};
let mut node = UntrackedCacheDir {
name: name.to_vec(),
stat: fs::symlink_metadata(dir)
.map(|metadata| untracked_cache_stat_data(&metadata))
.unwrap_or_default(),
exclude_oid,
valid: true,
check_only,
recurse: true,
..UntrackedCacheDir::default()
};
let result = (|| -> Result<()> {
let mut git_path = dir_git_path.to_vec();
for entry in entries {
let file_name = entry.file_name();
if file_name == std::ffi::OsStr::new(".git") {
continue;
}
let path_len = git_path_push_component(&mut git_path, &file_name);
let entry_result = (|| -> Result<()> {
if tracked.tracked_kind(&git_path).is_some() {
return Ok(());
}
let file_type = entry.file_type()?;
let is_dir = file_type.is_dir();
if ignores.is_ignored(&git_path, is_dir) {
return Ok(());
}
if file_type.is_file() || file_type.is_symlink() {
node.untracked.push(component_name_bytes(&file_name));
return Ok(());
}
if !is_dir {
return Ok(());
}
let path = entry.path();
if is_same_path(&path, git_dir) {
return Ok(());
}
let component = component_name_bytes(&file_name);
let tracked_directory = tracked.tracked_directory_kind(&git_path);
let child_check_only = matches!(untracked_mode, StatusUntrackedMode::Normal)
&& tracked_directory.is_none();
let child = build_untracked_cache_dir(
worktree_root,
git_dir,
&path,
&git_path,
&component,
tracked,
ignores,
untracked_mode,
format,
child_check_only,
)?;
let child_has_untracked = !child.untracked.is_empty()
|| child
.dirs
.iter()
.any(|dir| !dir.untracked.is_empty() || !dir.dirs.is_empty());
match untracked_mode {
StatusUntrackedMode::All => {
node.dirs.push(child);
}
StatusUntrackedMode::Normal => {
if tracked_directory.is_some() {
node.dirs.push(child);
} else {
if child_has_untracked {
let mut directory = component.clone();
directory.push(b'/');
node.untracked.push(directory);
}
node.dirs.push(child);
}
}
StatusUntrackedMode::None => {}
}
Ok(())
})();
git_path.truncate(path_len);
entry_result?;
}
Ok(())
})();
ignores.truncate(ignore_len);
result?;
if worktree_root == dir {
node.name.clear();
}
Ok(node)
}
fn component_name_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
name.as_bytes().to_vec()
}
#[cfg(not(unix))]
{
name.to_string_lossy().as_bytes().to_vec()
}
}
fn per_directory_ignore_oid(dir: &Path, format: ObjectFormat) -> Result<Option<ObjectId>> {
let path = dir.join(".gitignore");
match fs::read(&path) {
Ok(bytes) => Ok(Some(untracked_cache_exclude_oid(bytes, format)?)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
fn untracked_cache_oid_stat(path: &Path, format: ObjectFormat) -> Result<UntrackedCacheOidStat> {
let stat = fs::symlink_metadata(path)
.map(|metadata| untracked_cache_stat_data(&metadata))
.unwrap_or_default();
let oid = match fs::read(path) {
Ok(bytes) => untracked_cache_exclude_oid(bytes, format)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => ObjectId::null(format),
Err(err) => return Err(err.into()),
};
Ok(UntrackedCacheOidStat { stat, oid })
}
fn untracked_cache_exclude_oid(mut bytes: Vec<u8>, format: ObjectFormat) -> Result<ObjectId> {
if !bytes.is_empty() {
bytes.push(b'\n');
}
EncodedObject::new(ObjectType::Blob, bytes).object_id(format)
}
#[cfg(unix)]
fn untracked_cache_stat_data(metadata: &fs::Metadata) -> UntrackedCacheStatData {
use std::os::unix::fs::MetadataExt;
UntrackedCacheStatData {
ctime_seconds: metadata.ctime().min(u32::MAX as i64).max(0) as u32,
ctime_nanoseconds: metadata.ctime_nsec().min(u32::MAX as i64).max(0) as u32,
mtime_seconds: metadata.mtime().min(u32::MAX as i64).max(0) as u32,
mtime_nanoseconds: metadata.mtime_nsec().min(u32::MAX as i64).max(0) as u32,
dev: metadata.dev() as u32,
ino: metadata.ino() as u32,
uid: metadata.uid(),
gid: metadata.gid(),
size: metadata.size().min(u32::MAX as u64) as u32,
}
}
#[cfg(not(unix))]
fn untracked_cache_stat_data(metadata: &fs::Metadata) -> UntrackedCacheStatData {
let (mtime_seconds, mtime_nanoseconds) = file_mtime_parts(metadata).unwrap_or((0, 0));
UntrackedCacheStatData {
mtime_seconds: mtime_seconds.min(u64::from(u32::MAX)) as u32,
mtime_nanoseconds: mtime_nanoseconds.min(u64::from(u32::MAX)) as u32,
size: metadata.len().min(u64::from(u32::MAX)) as u32,
..UntrackedCacheStatData::default()
}
}
fn untracked_cache_dir_flags(untracked_mode: StatusUntrackedMode) -> u32 {
match untracked_mode {
StatusUntrackedMode::All => 0,
StatusUntrackedMode::Normal | StatusUntrackedMode::None => {
sley_index::untracked_cache_normal_flags()
}
}
}
fn untracked_cache_ident(worktree_root: &Path) -> Vec<u8> {
let mut ident = format!(
"Location {}, system {}",
worktree_root.display(),
untracked_cache_system_name()
)
.into_bytes();
ident.push(0);
ident
}
fn untracked_cache_system_name() -> String {
fs::read_to_string("/proc/sys/kernel/ostype")
.ok()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| {
let os = std::env::consts::OS;
let mut chars = os.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => "Unknown".to_string(),
}
})
}
fn push_untracked_directory(paths: &mut Vec<Vec<u8>>, git_path: &[u8]) {
paths.push(untracked_directory_path(git_path));
}
fn untracked_directory_path(git_path: &[u8]) -> Vec<u8> {
let mut directory = git_path.to_vec();
if directory.last() != Some(&b'/') {
directory.push(b'/');
}
directory
}
fn untracked_normal_rollup_path(
file_path: &[u8],
index: &BTreeMap<Vec<u8>, TrackedEntry>,
ignores: &IgnoreMatcher,
) -> Vec<u8> {
let segments = file_path
.split(|byte| *byte == b'/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if segments.len() <= 1 {
return file_path.to_vec();
}
let mut prefix = Vec::new();
for segment in &segments[..segments.len() - 1] {
if !prefix.is_empty() {
prefix.push(b'/');
}
prefix.extend_from_slice(segment);
if index_has_path_under(index, &prefix) {
break;
}
if !ignores.is_ignored(&prefix, true) {
let mut directory = prefix;
directory.push(b'/');
return directory;
}
}
file_path.to_vec()
}
fn ignored_traditional_rollup_path(
root: &Path,
git_dir: &Path,
path: &[u8],
index: &BTreeMap<Vec<u8>, TrackedEntry>,
ignores: &IgnoreMatcher,
) -> Result<Vec<u8>> {
let rolled = untracked_normal_rollup_path(path, index, ignores);
if rolled == path {
return Ok(rolled);
}
let Some(directory_path) = rolled.strip_suffix(b"/") else {
return Ok(rolled);
};
if ignores.is_ignored(directory_path, true) {
return Ok(rolled);
}
let mut absolute = PathBuf::new();
set_worktree_path_from_repo_path(root, directory_path, &mut absolute)?;
if directory_has_file(&absolute, root, git_dir, ignores)? {
return Ok(path.to_vec());
}
Ok(rolled)
}
fn directory_has_file(
dir: &Path,
root: &Path,
git_dir: &Path,
ignores: &IgnoreMatcher,
) -> Result<bool> {
if is_same_path(dir, git_dir) {
return Ok(false);
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if is_dot_git_entry(&path) {
continue;
}
if is_embedded_git_internals(root, &path) {
continue;
}
if is_same_path(&path, git_dir) {
continue;
}
let metadata = entry.metadata()?;
let relative = path.strip_prefix(root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if ignores.is_ignored(&git_path, metadata.is_dir()) {
continue;
}
if metadata.is_file() || metadata.file_type().is_symlink() {
return Ok(true);
}
if metadata.is_dir() {
if is_nested_repository_boundary(&path, git_dir) {
continue;
}
if directory_has_file(&path, root, git_dir, ignores)? {
return Ok(true);
}
}
}
Ok(false)
}
fn directory_has_ignored(
dir: &Path,
root: &Path,
git_dir: &Path,
ignores: &IgnoreMatcher,
) -> Result<bool> {
if is_same_path(dir, git_dir) {
return Ok(false);
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if is_dot_git_entry(&path) {
continue;
}
if is_same_path(&path, git_dir) {
continue;
}
let metadata = entry.metadata()?;
let relative = path.strip_prefix(root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if ignores.is_ignored(&git_path, metadata.is_dir()) {
return Ok(true);
}
if metadata.is_dir() && directory_has_ignored(&path, root, git_dir, ignores)? {
return Ok(true);
}
}
Ok(false)
}
fn ignored_untracked_paths(
root: &Path,
git_dir: &Path,
index: &BTreeMap<Vec<u8>, TrackedEntry>,
ignores: &IgnoreMatcher,
directory: bool,
) -> Result<Vec<Vec<u8>>> {
let mut paths = BTreeSet::new();
let context = IgnoredUntrackedContext {
root,
git_dir,
index,
ignores,
directory,
};
collect_ignored_untracked_paths(&context, root, false, &mut paths)?;
Ok(paths.into_iter().collect())
}
fn ignored_traditional_path_is_empty_directory(root: &Path, path: &[u8]) -> Result<bool> {
let Some(path) = path.strip_suffix(b"/") else {
return Ok(false);
};
let mut absolute = PathBuf::new();
set_worktree_path_from_repo_path(root, path, &mut absolute)?;
match fs::read_dir(&absolute) {
Ok(mut entries) => Ok(entries.next().is_none()),
Err(err) if err.kind() == std::io::ErrorKind::NotADirectory => Ok(false),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err.into()),
}
}
struct IgnoredUntrackedContext<'a> {
root: &'a Path,
git_dir: &'a Path,
index: &'a BTreeMap<Vec<u8>, TrackedEntry>,
ignores: &'a IgnoreMatcher,
directory: bool,
}
fn collect_ignored_untracked_paths(
context: &IgnoredUntrackedContext<'_>,
dir: &Path,
parent_ignored: bool,
paths: &mut BTreeSet<Vec<u8>>,
) -> Result<()> {
if is_same_path(dir, context.git_dir) {
return Ok(());
}
let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if is_dot_git_entry(&path) {
continue;
}
if is_same_path(&path, context.git_dir) {
continue;
}
let metadata = entry.metadata()?;
let relative = path.strip_prefix(context.root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if metadata.is_dir() {
let ignored = parent_ignored || context.ignores.is_ignored(&git_path, true);
if ignored && !index_has_path_under(context.index, &git_path) {
if context.directory || is_nested_repository_boundary(&path, context.git_dir) {
let mut directory_path = git_path;
directory_path.push(b'/');
paths.insert(directory_path);
} else {
collect_ignored_untracked_paths(context, &path, true, paths)?;
}
} else {
if is_nested_repository_boundary(&path, context.git_dir) {
continue;
}
collect_ignored_untracked_paths(context, &path, ignored, paths)?;
}
} else if !context.index.contains_key(&git_path)
&& (metadata.is_file() || metadata.file_type().is_symlink())
&& (parent_ignored || context.ignores.is_ignored(&git_path, false))
{
paths.insert(git_path);
}
}
Ok(())
}
#[derive(Debug, Default)]
struct IgnoreMatcher {
patterns: Vec<IgnorePattern>,
buckets: IgnorePatternBuckets,
}
#[derive(Debug, Default)]
struct IgnorePatternBuckets {
literal_basename: HashMap<Vec<u8>, Vec<usize>>,
directory_literal_basename: HashMap<Vec<u8>, Vec<usize>>,
literal_path_basename: HashMap<Vec<u8>, Vec<usize>>,
directory_literal_path_basename: HashMap<Vec<u8>, Vec<usize>>,
path_suffix_basename: HashMap<Vec<u8>, Vec<usize>>,
directory_path_suffix_basename: HashMap<Vec<u8>, Vec<usize>>,
glob_path_literal_basename: HashMap<Vec<u8>, Vec<usize>>,
glob_directory_literal_basename: HashMap<Vec<u8>, Vec<usize>>,
glob_path_suffix_basename: Vec<usize>,
glob_path_prefix_basename: Vec<usize>,
glob_directory_suffix_basename: Vec<usize>,
glob_directory_prefix_basename: Vec<usize>,
suffix_basename: HashMap<u8, Vec<usize>>,
prefix_basename: HashMap<u8, Vec<usize>>,
other: Vec<usize>,
}
impl IgnorePatternBuckets {
fn push(&mut self, index: usize, pattern: &IgnorePattern) {
match pattern.bucket_kind() {
IgnoreBucketKind::LiteralBasename => self
.literal_basename
.entry(pattern.pattern.clone())
.or_default()
.push(index),
IgnoreBucketKind::DirectoryLiteralBasename => self
.directory_literal_basename
.entry(pattern.pattern.clone())
.or_default()
.push(index),
IgnoreBucketKind::LiteralPathBasename => self
.literal_path_basename
.entry(path_basename(&pattern.pattern).to_vec())
.or_default()
.push(index),
IgnoreBucketKind::DirectoryLiteralPathBasename => self
.directory_literal_path_basename
.entry(path_basename(&pattern.pattern).to_vec())
.or_default()
.push(index),
IgnoreBucketKind::PathSuffixBasename => {
let suffix = pattern
.pattern
.strip_prefix(b"**/")
.unwrap_or(&pattern.pattern);
self.path_suffix_basename
.entry(path_basename(suffix).to_vec())
.or_default()
.push(index);
}
IgnoreBucketKind::DirectoryPathSuffixBasename => {
let suffix = pattern
.pattern
.strip_prefix(b"**/")
.unwrap_or(&pattern.pattern);
self.directory_path_suffix_basename
.entry(path_basename(suffix).to_vec())
.or_default()
.push(index);
}
IgnoreBucketKind::GlobPathLiteralBasename => self
.glob_path_literal_basename
.entry(path_basename(&pattern.pattern).to_vec())
.or_default()
.push(index),
IgnoreBucketKind::GlobDirectoryLiteralBasename => self
.glob_directory_literal_basename
.entry(path_basename(&pattern.pattern).to_vec())
.or_default()
.push(index),
IgnoreBucketKind::GlobPathSuffixBasename => self.glob_path_suffix_basename.push(index),
IgnoreBucketKind::GlobPathPrefixBasename => self.glob_path_prefix_basename.push(index),
IgnoreBucketKind::GlobDirectorySuffixBasename => {
self.glob_directory_suffix_basename.push(index)
}
IgnoreBucketKind::GlobDirectoryPrefixBasename => {
self.glob_directory_prefix_basename.push(index)
}
IgnoreBucketKind::SuffixBasename => self
.suffix_basename
.entry(*pattern.pattern.last().expect("suffix literal is non-empty"))
.or_default()
.push(index),
IgnoreBucketKind::PrefixBasename => self
.prefix_basename
.entry(pattern.pattern[0])
.or_default()
.push(index),
IgnoreBucketKind::Other => self.other.push(index),
}
}
fn truncate(&mut self, len: usize) {
fn truncate_indices(indices: &mut Vec<usize>, len: usize) {
let keep = indices.partition_point(|index| *index < len);
indices.truncate(keep);
}
for indices in self.literal_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.directory_literal_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.literal_path_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.directory_literal_path_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.path_suffix_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.directory_path_suffix_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.glob_path_literal_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.glob_directory_literal_basename.values_mut() {
truncate_indices(indices, len);
}
truncate_indices(&mut self.glob_path_suffix_basename, len);
truncate_indices(&mut self.glob_path_prefix_basename, len);
truncate_indices(&mut self.glob_directory_suffix_basename, len);
truncate_indices(&mut self.glob_directory_prefix_basename, len);
for indices in self.suffix_basename.values_mut() {
truncate_indices(indices, len);
}
for indices in self.prefix_basename.values_mut() {
truncate_indices(indices, len);
}
truncate_indices(&mut self.other, len);
}
fn profile_map_count(&self) -> usize {
self.literal_basename.len()
+ self.directory_literal_basename.len()
+ self.literal_path_basename.len()
+ self.directory_literal_path_basename.len()
+ self.path_suffix_basename.len()
+ self.directory_path_suffix_basename.len()
+ self.glob_path_literal_basename.len()
+ self.glob_directory_literal_basename.len()
+ self.suffix_basename.len()
+ self.prefix_basename.len()
}
fn profile_index_count(&self) -> usize {
fn map_indices<K>(map: &HashMap<K, Vec<usize>>) -> usize {
map.values().map(Vec::len).sum()
}
map_indices(&self.literal_basename)
+ map_indices(&self.directory_literal_basename)
+ map_indices(&self.literal_path_basename)
+ map_indices(&self.directory_literal_path_basename)
+ map_indices(&self.path_suffix_basename)
+ map_indices(&self.directory_path_suffix_basename)
+ map_indices(&self.glob_path_literal_basename)
+ map_indices(&self.glob_directory_literal_basename)
+ self.glob_path_suffix_basename.len()
+ self.glob_path_prefix_basename.len()
+ self.glob_directory_suffix_basename.len()
+ self.glob_directory_prefix_basename.len()
+ map_indices(&self.suffix_basename)
+ map_indices(&self.prefix_basename)
+ self.other.len()
}
fn profile_index_vec_bytes(&self) -> usize {
fn map_bytes<K>(map: &HashMap<K, Vec<usize>>) -> usize {
map.values()
.map(|indices| indices.capacity() * std::mem::size_of::<usize>())
.sum()
}
map_bytes(&self.literal_basename)
+ map_bytes(&self.directory_literal_basename)
+ map_bytes(&self.literal_path_basename)
+ map_bytes(&self.directory_literal_path_basename)
+ map_bytes(&self.path_suffix_basename)
+ map_bytes(&self.directory_path_suffix_basename)
+ map_bytes(&self.glob_path_literal_basename)
+ map_bytes(&self.glob_directory_literal_basename)
+ self.glob_path_suffix_basename.capacity() * std::mem::size_of::<usize>()
+ self.glob_path_prefix_basename.capacity() * std::mem::size_of::<usize>()
+ self.glob_directory_suffix_basename.capacity() * std::mem::size_of::<usize>()
+ self.glob_directory_prefix_basename.capacity() * std::mem::size_of::<usize>()
+ map_bytes(&self.suffix_basename)
+ map_bytes(&self.prefix_basename)
+ self.other.capacity() * std::mem::size_of::<usize>()
}
}
#[derive(Debug)]
struct IgnorePattern {
base: Vec<u8>,
pattern: Vec<u8>,
original: Vec<u8>,
source: Vec<u8>,
line_number: usize,
negated: bool,
directory_only: bool,
anchored: bool,
has_slash: bool,
match_kind: MatchKind,
glob_literal_prefix_len: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MatchKind {
Literal,
Suffix,
Prefix,
PathSuffix,
Glob,
}
fn path_basename(path: &[u8]) -> &[u8] {
path.rsplit(|byte| *byte == b'/').next().unwrap_or(path)
}
fn path_component_has_glob_meta(component: &[u8]) -> bool {
component
.iter()
.any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'\\'))
}
fn final_component_match_kind(pattern: &[u8]) -> MatchKind {
classify_ignore_pattern(path_basename(pattern))
}
fn visit_directory_match_components(path: &[u8], is_dir: bool, mut visit: impl FnMut(&[u8])) {
let mut start = 0usize;
for (index, byte) in path.iter().enumerate() {
if *byte == b'/' {
if index > start {
visit(&path[start..index]);
}
start = index + 1;
}
}
if is_dir && start < path.len() {
visit(&path[start..]);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IgnoreBucketKind {
LiteralBasename,
DirectoryLiteralBasename,
LiteralPathBasename,
DirectoryLiteralPathBasename,
PathSuffixBasename,
DirectoryPathSuffixBasename,
GlobPathLiteralBasename,
GlobDirectoryLiteralBasename,
GlobPathSuffixBasename,
GlobPathPrefixBasename,
GlobDirectorySuffixBasename,
GlobDirectoryPrefixBasename,
SuffixBasename,
PrefixBasename,
Other,
}
fn classify_ignore_pattern(pattern: &[u8]) -> MatchKind {
if let Some(suffix) = pattern.strip_prefix(b"**/")
&& !suffix.is_empty()
&& !suffix
.iter()
.any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'\\'))
{
return MatchKind::PathSuffix;
}
let stars = pattern.iter().filter(|byte| **byte == b'*').count();
let other_meta = pattern
.iter()
.any(|byte| matches!(byte, b'?' | b'[' | b'\\'));
if stars == 0 && !other_meta {
return MatchKind::Literal;
}
if stars == 1 && !other_meta {
let literal = if pattern.first() == Some(&b'*') {
Some((&pattern[1..], MatchKind::Suffix))
} else if pattern.last() == Some(&b'*') {
Some((&pattern[..pattern.len() - 1], MatchKind::Prefix))
} else {
None
};
if let Some((literal, kind)) = literal
&& !literal.is_empty()
&& !literal.contains(&b'/')
{
return kind;
}
}
MatchKind::Glob
}
impl IgnoreMatcher {
fn emit_memory_profile(&self, label: &str) {
let pattern_payload_bytes = self
.patterns
.iter()
.map(|pattern| {
pattern.base.capacity()
+ pattern.pattern.capacity()
+ pattern.original.capacity()
+ pattern.source.capacity()
})
.sum();
status_profile_mem(
label,
&[
("ignore_patterns_len", self.patterns.len()),
("ignore_patterns_cap", self.patterns.capacity()),
(
"ignore_pattern_struct_bytes",
self.patterns.capacity() * std::mem::size_of::<IgnorePattern>(),
),
("ignore_pattern_payload_bytes", pattern_payload_bytes),
("ignore_bucket_map_count", self.buckets.profile_map_count()),
(
"ignore_bucket_index_count",
self.buckets.profile_index_count(),
),
(
"ignore_bucket_index_vec_bytes",
self.buckets.profile_index_vec_bytes(),
),
],
);
}
fn from_sources(
root: &Path,
exclude_standard: bool,
patterns: &[Vec<u8>],
per_directory: &[String],
) -> Result<Self> {
let mut matcher = if exclude_standard {
Self::from_worktree_root(root)?
} else {
Self::default()
};
matcher.extend_patterns(patterns);
matcher.extend_per_directory_patterns(root, per_directory)?;
Ok(matcher)
}
fn from_worktree_base(root: &Path) -> Result<Self> {
let mut matcher = Self::default();
if !read_core_excludes_file(root, &mut matcher.patterns) {
read_default_global_excludes_file(&mut matcher.patterns);
}
read_ignore_patterns(
root.join(".git").join("info").join("exclude"),
&mut matcher.patterns,
&[],
b".git/info/exclude",
);
matcher.rebuild_buckets();
Ok(matcher)
}
fn from_worktree_root(root: &Path) -> Result<Self> {
let mut matcher = Self::default();
if !read_core_excludes_file(root, &mut matcher.patterns) {
read_default_global_excludes_file(&mut matcher.patterns);
}
read_ignore_patterns(
root.join(".git").join("info").join("exclude"),
&mut matcher.patterns,
&[],
b".git/info/exclude",
);
matcher.rebuild_buckets();
collect_per_directory_patterns_into_matcher(
root,
root,
&[String::from(".gitignore")],
&mut matcher,
)?;
Ok(matcher)
}
fn extend_patterns(&mut self, patterns: &[Vec<u8>]) {
for pattern in patterns {
self.push_raw_pattern(pattern, &[], &[], 0);
}
}
fn extend_per_directory_patterns(&mut self, root: &Path, names: &[String]) -> Result<()> {
if names.is_empty() {
return Ok(());
}
collect_per_directory_patterns_into_matcher(root, root, names, self)?;
Ok(())
}
fn is_ignored(&self, path: &[u8], is_dir: bool) -> bool {
self.is_ignored_profiled(path, is_dir, None)
}
fn match_for(&self, path: &[u8], is_dir: bool) -> Option<&IgnorePattern> {
self.match_index_for(path, is_dir, None)
.and_then(|index| self.patterns.get(index))
}
fn is_ignored_profiled(
&self,
path: &[u8],
is_dir: bool,
mut profile: Option<&mut StatusProfileCounters>,
) -> bool {
if let Some(profile) = profile.as_deref_mut() {
profile.ignore_checks += 1;
}
self.match_index_for(path, is_dir, profile)
.is_some_and(|index| !self.patterns[index].negated)
}
fn match_index_for(
&self,
path: &[u8],
is_dir: bool,
mut profile: Option<&mut StatusProfileCounters>,
) -> Option<usize> {
let basename = path_basename(path);
let mut best = None;
if let Some(indices) = self.buckets.literal_basename.get(basename) {
self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
}
if let Some(indices) = self.buckets.literal_path_basename.get(basename) {
self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
}
if let Some(indices) = self.buckets.path_suffix_basename.get(basename) {
self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
}
if let Some(indices) = self.buckets.glob_path_literal_basename.get(basename) {
self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
}
self.match_final_component_candidates(
&self.buckets.glob_path_suffix_basename,
MatchKind::Suffix,
basename,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
self.match_final_component_candidates(
&self.buckets.glob_path_prefix_basename,
MatchKind::Prefix,
basename,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
visit_directory_match_components(path, is_dir, |component| {
if let Some(indices) = self.buckets.directory_literal_basename.get(component) {
self.match_bucket_candidates(
indices,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
}
if let Some(indices) = self.buckets.directory_literal_path_basename.get(component) {
self.match_bucket_candidates(
indices,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
}
if let Some(indices) = self.buckets.directory_path_suffix_basename.get(component) {
self.match_bucket_candidates(
indices,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
}
if let Some(indices) = self.buckets.glob_directory_literal_basename.get(component) {
self.match_bucket_candidates(
indices,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
}
self.match_final_component_candidates(
&self.buckets.glob_directory_suffix_basename,
MatchKind::Suffix,
component,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
self.match_final_component_candidates(
&self.buckets.glob_directory_prefix_basename,
MatchKind::Prefix,
component,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
});
if let Some(last) = basename.last()
&& let Some(indices) = self.buckets.suffix_basename.get(last)
{
self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
}
if let Some(first) = basename.first()
&& let Some(indices) = self.buckets.prefix_basename.get(first)
{
self.match_bucket_candidates(indices, path, basename, is_dir, &mut best, &mut profile);
}
self.match_bucket_candidates(
&self.buckets.other,
path,
basename,
is_dir,
&mut best,
&mut profile,
);
best
}
fn match_bucket_candidates(
&self,
indices: &[usize],
path: &[u8],
basename: &[u8],
is_dir: bool,
best: &mut Option<usize>,
profile: &mut Option<&mut StatusProfileCounters>,
) {
for &index in indices.iter().rev() {
if best.is_some_and(|best| index <= best) {
break;
}
let pattern = &self.patterns[index];
if !pattern.base_matches(path) {
continue;
}
if !pattern.glob_literal_prefix_matches(path, basename, is_dir) {
continue;
}
if let Some(profile) = profile.as_deref_mut() {
profile.ignore_pattern_tests += 1;
if pattern.match_kind == MatchKind::Glob {
profile.ignore_glob_fallback_tests += 1;
}
}
if pattern.matches_with_basename(path, basename, is_dir) {
*best = Some(index);
break;
}
}
}
fn match_final_component_candidates(
&self,
indices: &[usize],
kind: MatchKind,
component: &[u8],
path: &[u8],
basename: &[u8],
is_dir: bool,
best: &mut Option<usize>,
profile: &mut Option<&mut StatusProfileCounters>,
) {
for &index in indices.iter().rev() {
if best.is_some_and(|best| index <= best) {
break;
}
let pattern = &self.patterns[index];
if !pattern.base_matches(path) {
continue;
}
let final_component = path_basename(&pattern.pattern);
let candidate = match kind {
MatchKind::Suffix => component.ends_with(&final_component[1..]),
MatchKind::Prefix => {
component.starts_with(&final_component[..final_component.len() - 1])
}
_ => false,
};
if !candidate {
continue;
}
if !pattern.glob_literal_prefix_matches(path, basename, is_dir) {
continue;
}
if let Some(profile) = profile.as_deref_mut() {
profile.ignore_pattern_tests += 1;
if pattern.match_kind == MatchKind::Glob {
profile.ignore_glob_fallback_tests += 1;
}
}
if pattern.matches_with_basename(path, basename, is_dir) {
*best = Some(index);
break;
}
}
}
fn push_pattern(&mut self, pattern: IgnorePattern) {
let index = self.patterns.len();
self.buckets.push(index, &pattern);
self.patterns.push(pattern);
}
fn push_raw_pattern(&mut self, raw: &[u8], base: &[u8], source: &[u8], line_number: usize) {
if let Some(pattern) = parse_ignore_pattern(raw, base, source, line_number) {
self.push_pattern(pattern);
}
}
fn truncate(&mut self, len: usize) {
if self.patterns.len() == len {
return;
}
self.patterns.truncate(len);
self.buckets.truncate(len);
}
fn rebuild_buckets(&mut self) {
let mut buckets = IgnorePatternBuckets::default();
for (index, pattern) in self.patterns.iter().enumerate() {
buckets.push(index, pattern);
}
self.buckets = buckets;
}
}
#[derive(Debug)]
enum SparseMatcher {
Full { patterns: Vec<IgnorePattern> },
Cone(ConeMatcher),
}
#[derive(Debug, Default)]
struct ConeMatcher {
root_files: bool,
recursive_dirs: Vec<Vec<u8>>,
parent_dirs: Vec<Vec<u8>>,
}
impl SparseMatcher {
fn new(sparse: &SparseCheckout, mode: SparseCheckoutMode) -> Self {
let resolved = match mode {
SparseCheckoutMode::Auto => {
if patterns_are_cone(&sparse.patterns) {
SparseCheckoutMode::Cone
} else {
SparseCheckoutMode::Full
}
}
other => other,
};
match resolved {
SparseCheckoutMode::Cone => SparseMatcher::Cone(ConeMatcher::compile(&sparse.patterns)),
_ => {
let mut patterns = Vec::new();
for pattern in &sparse.patterns {
push_ignore_pattern(&mut patterns, pattern, &[], b"sparse-checkout", 0);
}
SparseMatcher::Full { patterns }
}
}
}
fn includes_file(&self, path: &[u8]) -> bool {
match self {
SparseMatcher::Full { patterns } => {
let mut end = path.len();
let mut is_dir = false;
while end > 0 {
let candidate = &path[..end];
let mut matched = None;
for pattern in patterns {
if pattern.matches(candidate, is_dir) {
matched = Some(!pattern.negated);
}
}
if let Some(included) = matched {
return included;
}
let Some(slash) = candidate.iter().rposition(|byte| *byte == b'/') else {
break;
};
end = slash;
is_dir = true;
}
false
}
SparseMatcher::Cone(cone) => cone.includes_file(path),
}
}
}
impl ConeMatcher {
fn compile(patterns: &[Vec<u8>]) -> Self {
let mut matcher = ConeMatcher::default();
let mut positive_dirs = Vec::new();
let mut guarded_parent_dirs = BTreeSet::new();
for raw in patterns {
let line = sparse_clean_line(raw);
if line.is_empty() || line.starts_with(b"#") {
continue;
}
if line.starts_with(b"!") {
if let Some(rest) = line.strip_prefix(b"!/")
&& let Some(dir) = rest.strip_suffix(b"/*/")
&& !dir.is_empty()
{
guarded_parent_dirs.insert(unescape_sparse_cone_dir(dir));
}
continue;
}
if line == b"/*" {
matcher.root_files = true;
continue;
}
if let Some(rest) = line.strip_prefix(b"/")
&& let Some(dir) = rest.strip_suffix(b"/")
&& !dir.is_empty()
{
positive_dirs.push(unescape_sparse_cone_dir(dir));
continue;
}
if let Some(rest) = line.strip_prefix(b"/")
&& let Some(dir) = rest.strip_suffix(b"/*")
&& !dir.is_empty()
{
matcher.parent_dirs.push(unescape_sparse_cone_dir(dir));
continue;
}
}
for dir in positive_dirs {
if guarded_parent_dirs.contains(&dir) {
matcher.parent_dirs.push(dir);
} else {
matcher.recursive_dirs.push(dir);
}
}
matcher
}
fn includes_file(&self, path: &[u8]) -> bool {
let parent = match path.iter().rposition(|byte| *byte == b'/') {
Some(index) => &path[..index],
None => {
return self.root_files;
}
};
if self
.recursive_dirs
.iter()
.any(|dir| path_is_under_dir(path, dir))
{
return true;
}
self.parent_dirs.iter().any(|dir| dir.as_slice() == parent)
}
}
fn sparse_clean_line(raw: &[u8]) -> &[u8] {
let line = raw.strip_suffix(b"\r").unwrap_or(raw);
trim_ascii_whitespace(line)
}
fn path_is_under_dir(path: &[u8], dir: &[u8]) -> bool {
if dir.is_empty() {
return true;
}
path.strip_prefix(dir)
.is_some_and(|rest| rest.first() == Some(&b'/'))
}
fn patterns_are_cone(patterns: &[Vec<u8>]) -> bool {
let mut saw_pattern = false;
for raw in patterns {
let line = sparse_clean_line(raw);
if line.is_empty() || line.starts_with(b"#") {
continue;
}
saw_pattern = true;
let body = line.strip_prefix(b"!").unwrap_or(line);
let is_cone_shaped = body == b"/*"
|| body == b"/*/"
|| (body.starts_with(b"/")
&& (body.ends_with(b"/") || body.ends_with(b"/*"))
&& !sparse_has_unescaped_glob_meta(body));
if !is_cone_shaped {
return false;
}
}
saw_pattern
}
fn sparse_has_unescaped_glob_meta(body: &[u8]) -> bool {
let trimmed = body.strip_suffix(b"/*").unwrap_or(body);
for (index, byte) in trimmed.iter().enumerate() {
if !matches!(*byte, b'*' | b'?' | b'[' | b']' | b'\\') {
continue;
}
let prev = index.checked_sub(1).and_then(|i| trimmed.get(i)).copied();
let next = trimmed.get(index + 1).copied();
if prev == Some(b'\\') {
continue;
}
if *byte == b'\\' && matches!(next, Some(b'*' | b'?' | b'[' | b'\\')) {
continue;
}
return true;
}
false
}
fn unescape_sparse_cone_dir(path: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(path.len());
let mut iter = path.iter().copied();
while let Some(byte) = iter.next() {
if byte == b'\\'
&& let Some(next @ (b'*' | b'?' | b'[' | b'\\')) = iter.next()
{
out.push(next);
continue;
}
out.push(byte);
}
out
}
fn read_core_excludes_file(root: &Path, patterns: &mut Vec<IgnorePattern>) -> bool {
let Ok(config) = sley_config::read_repo_config(&root.join(".git"), None) else {
return false;
};
let Some(value) = config.get("core", None, "excludesFile") else {
return false;
};
let path = expand_core_excludes_file(root, value);
read_ignore_patterns(path, patterns, &[], value.as_bytes());
true
}
fn expand_core_excludes_file(root: &Path, value: &str) -> PathBuf {
let path = Path::new(value);
if path.is_absolute() {
return path.to_path_buf();
}
if let Some(rest) = value.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
root.join(path)
}
fn read_default_global_excludes_file(patterns: &mut Vec<IgnorePattern>) {
if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME")
&& !config_home.is_empty()
{
let path = PathBuf::from(config_home).join("git").join("ignore");
let source = path.to_string_lossy().into_owned();
read_ignore_patterns(path, patterns, &[], source.as_bytes());
return;
}
if let Some(home) = std::env::var_os("HOME") {
let path = PathBuf::from(home)
.join(".config")
.join("git")
.join("ignore");
let source = path.to_string_lossy().into_owned();
read_ignore_patterns(path, patterns, &[], source.as_bytes());
}
}
fn collect_per_directory_patterns_into_matcher(
root: &Path,
dir: &Path,
names: &[String],
matcher: &mut IgnoreMatcher,
) -> Result<()> {
for name in names {
let path = dir.join(name);
let relative = dir.strip_prefix(root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", dir.display()))
})?;
let base = git_path_bytes(relative)?;
let mut source = base.clone();
if !source.is_empty() {
source.push(b'/');
}
source.extend_from_slice(name.as_bytes());
read_per_directory_ignore_patterns_into_matcher(&path, matcher, &base, &source)?;
}
let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if path.file_name().and_then(|name| name.to_str()) == Some(".git") {
continue;
}
let metadata = entry.file_type()?;
if metadata.is_symlink() {
continue;
}
if metadata.is_dir() {
let relative = path.strip_prefix(root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if !matcher.is_ignored(&git_path, true) {
collect_per_directory_patterns_into_matcher(root, &path, names, matcher)?;
}
}
}
Ok(())
}
fn read_ignore_patterns(
path: impl AsRef<Path>,
patterns: &mut Vec<IgnorePattern>,
base: &[u8],
source: &[u8],
) {
let Ok(contents) = fs::read(path) else {
return;
};
for (line, raw) in contents.split(|byte| *byte == b'\n').enumerate() {
push_ignore_pattern(patterns, raw, base, source, line + 1);
}
}
fn read_per_directory_ignore_patterns_into_matcher(
path: impl AsRef<Path>,
matcher: &mut IgnoreMatcher,
base: &[u8],
source: &[u8],
) -> Result<()> {
let path = path.as_ref();
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(GitError::Io(err.to_string())),
};
if metadata.file_type().is_symlink() {
return Err(GitError::Command(format!(
"unable to access '{}'",
path.display()
)));
}
if !metadata.is_file() {
return Ok(());
}
let contents = fs::read(path)?;
for (line, raw) in contents.split(|byte| *byte == b'\n').enumerate() {
matcher.push_raw_pattern(raw, base, source, line + 1);
}
Ok(())
}
fn push_ignore_pattern(
patterns: &mut Vec<IgnorePattern>,
raw: &[u8],
base: &[u8],
source: &[u8],
line_number: usize,
) {
if let Some(pattern) = parse_ignore_pattern(raw, base, source, line_number) {
patterns.push(pattern);
}
}
fn parse_ignore_pattern(
raw: &[u8],
base: &[u8],
source: &[u8],
line_number: usize,
) -> Option<IgnorePattern> {
let raw = if line_number == 1 {
raw.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(raw)
} else {
raw
};
let mut line = raw.strip_suffix(b"\r").unwrap_or(raw).to_vec();
normalize_ignore_trailing_spaces(&mut line);
let original = line.clone();
let mut line = line.as_slice();
if line.is_empty() || line.starts_with(b"#") {
return None;
}
let negated = if line.starts_with(b"\\#") || line.starts_with(b"\\!") {
line = &line[1..];
false
} else if let Some(pattern) = line.strip_prefix(b"!") {
line = pattern;
true
} else {
false
};
let directory_only = line.ends_with(b"/");
let pattern = if directory_only {
line.strip_suffix(b"/").unwrap_or(line)
} else {
line
};
let (anchored, pattern) = if let Some(pattern) = pattern.strip_prefix(b"/") {
(true, pattern)
} else {
(false, pattern)
};
let pattern = match pattern.strip_prefix(b"**/") {
Some(rest) if !rest.is_empty() && !rest.contains(&b'/') => rest,
_ => pattern,
};
if pattern.is_empty() {
return None;
}
let match_kind = classify_ignore_pattern(pattern);
let glob_literal_prefix_len = if match_kind == MatchKind::Glob {
pattern
.iter()
.position(|byte| matches!(byte, b'*' | b'?' | b'[' | b'\\'))
.unwrap_or(pattern.len())
} else {
0
};
Some(IgnorePattern {
base: base.to_vec(),
pattern: pattern.to_vec(),
original,
source: source.to_vec(),
line_number,
negated,
directory_only,
anchored,
has_slash: pattern.contains(&b'/'),
match_kind,
glob_literal_prefix_len,
})
}
fn normalize_ignore_trailing_spaces(line: &mut Vec<u8>) {
while line.last() == Some(&b' ') {
let space_index = line.len() - 1;
let backslashes = line[..space_index]
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count();
if backslashes % 2 == 1 {
line.remove(space_index - 1);
break;
}
line.pop();
}
}
impl IgnorePattern {
fn bucket_kind(&self) -> IgnoreBucketKind {
if self.match_kind == MatchKind::PathSuffix {
return if self.directory_only {
IgnoreBucketKind::DirectoryPathSuffixBasename
} else {
IgnoreBucketKind::PathSuffixBasename
};
}
if (self.anchored || self.has_slash) && self.match_kind == MatchKind::Literal {
return if self.directory_only {
IgnoreBucketKind::DirectoryLiteralPathBasename
} else {
IgnoreBucketKind::LiteralPathBasename
};
}
if self.has_slash
&& self.match_kind == MatchKind::Glob
&& !self.directory_only
&& !path_component_has_glob_meta(path_basename(&self.pattern))
{
return IgnoreBucketKind::GlobPathLiteralBasename;
}
if self.has_slash
&& self.match_kind == MatchKind::Glob
&& self.directory_only
&& !path_component_has_glob_meta(path_basename(&self.pattern))
{
return IgnoreBucketKind::GlobDirectoryLiteralBasename;
}
if self.has_slash && self.match_kind == MatchKind::Glob {
return match (
self.directory_only,
final_component_match_kind(&self.pattern),
) {
(false, MatchKind::Suffix) => IgnoreBucketKind::GlobPathSuffixBasename,
(false, MatchKind::Prefix) => IgnoreBucketKind::GlobPathPrefixBasename,
(true, MatchKind::Suffix) => IgnoreBucketKind::GlobDirectorySuffixBasename,
(true, MatchKind::Prefix) => IgnoreBucketKind::GlobDirectoryPrefixBasename,
_ => IgnoreBucketKind::Other,
};
}
if self.anchored || self.has_slash {
return IgnoreBucketKind::Other;
}
match (self.directory_only, self.match_kind) {
(false, MatchKind::Literal) => IgnoreBucketKind::LiteralBasename,
(true, MatchKind::Literal) => IgnoreBucketKind::DirectoryLiteralBasename,
(false, MatchKind::Suffix) => IgnoreBucketKind::SuffixBasename,
(false, MatchKind::Prefix) => IgnoreBucketKind::PrefixBasename,
_ => IgnoreBucketKind::Other,
}
}
fn base_matches(&self, path: &[u8]) -> bool {
if self.base.is_empty() {
return true;
}
path.strip_prefix(self.base.as_slice())
.is_some_and(|rest| rest.starts_with(b"/"))
}
fn to_match(&self) -> IgnoreMatch {
IgnoreMatch {
source: self.source.clone(),
line_number: self.line_number,
pattern: self.original.clone(),
ignored: !self.negated,
}
}
fn matches(&self, path: &[u8], is_dir: bool) -> bool {
let basename = path_basename(path);
self.matches_with_basename(path, basename, is_dir)
}
fn glob_literal_prefix_matches(&self, path: &[u8], basename: &[u8], is_dir: bool) -> bool {
if self.match_kind != MatchKind::Glob {
return true;
}
if self.glob_literal_prefix_len == 0 {
return true;
}
let prefix = &self.pattern[..self.glob_literal_prefix_len];
let scoped_path = if self.base.is_empty() {
path
} else {
let Some(rest) = path
.strip_prefix(self.base.as_slice())
.and_then(|rest| rest.strip_prefix(b"/"))
else {
return false;
};
rest
};
if self.anchored || self.has_slash {
return scoped_path.starts_with(prefix);
}
if self.directory_only && !is_dir {
return true;
}
basename.starts_with(prefix)
}
fn matches_with_basename(&self, path: &[u8], basename: &[u8], is_dir: bool) -> bool {
let path = if self.base.is_empty() {
path
} else {
let Some(rest) = path
.strip_prefix(self.base.as_slice())
.and_then(|rest| rest.strip_prefix(b"/"))
else {
return false;
};
rest
};
if self.directory_only {
return self.matches_directory(path, is_dir);
}
if self.anchored || self.has_slash {
return self.match_segment(path);
}
self.match_segment(basename)
}
fn matches_directory(&self, path: &[u8], is_dir: bool) -> bool {
if self.anchored || self.has_slash {
if is_dir && self.match_path(path) {
return true;
}
if self.negated {
return false;
}
return path
.iter()
.enumerate()
.any(|(idx, byte)| *byte == b'/' && self.match_path(&path[..idx]));
}
let mut components = path.split(|byte| *byte == b'/').peekable();
while let Some(component) = components.next() {
if self.match_segment(component) && (is_dir || components.peek().is_some()) {
return true;
}
}
false
}
fn match_path(&self, value: &[u8]) -> bool {
match self.match_kind {
MatchKind::Literal => self.pattern == value,
MatchKind::Suffix => !value.contains(&b'/') && value.ends_with(&self.pattern[1..]),
MatchKind::Prefix => {
!value.contains(&b'/') && value.starts_with(&self.pattern[..self.pattern.len() - 1])
}
MatchKind::PathSuffix => {
let suffix = &self.pattern[3..];
value
.strip_suffix(suffix)
.is_some_and(|prefix| prefix.is_empty() || prefix.ends_with(b"/"))
}
MatchKind::Glob => wildcard_path_matches(&self.pattern, value),
}
}
fn match_segment(&self, value: &[u8]) -> bool {
self.match_path(value)
}
}
thread_local! {
static WILDCARD_MEMO: RefCell<Vec<Option<bool>>> = const { RefCell::new(Vec::new()) };
}
fn wildcard_path_matches(pattern: &[u8], value: &[u8]) -> bool {
let stride = value.len() + 1;
let cells = (pattern.len() + 1) * stride;
WILDCARD_MEMO.with_borrow_mut(|memo| {
memo.clear();
memo.resize(cells, None);
wildcard_path_matches_from(pattern, value, 0, 0, memo, stride)
})
}
fn wildcard_path_matches_from(
pattern: &[u8],
value: &[u8],
pattern_index: usize,
value_index: usize,
memo: &mut [Option<bool>],
stride: usize,
) -> bool {
let cell = pattern_index * stride + value_index;
if let Some(cached) = memo[cell] {
return cached;
}
let matched = if pattern_index == pattern.len() {
value_index == value.len()
} else {
match pattern[pattern_index] {
b'*' if pattern.get(pattern_index + 1) == Some(&b'*') => wildcard_double_star_matches(
pattern,
value,
pattern_index,
value_index,
memo,
stride,
),
b'*' => {
if wildcard_path_matches_from(
pattern,
value,
pattern_index + 1,
value_index,
memo,
stride,
) {
true
} else {
let mut next = value_index;
while next < value.len() && value[next] != b'/' {
next += 1;
if wildcard_path_matches_from(
pattern,
value,
pattern_index + 1,
next,
memo,
stride,
) {
return true;
}
}
false
}
}
b'?' => {
value_index < value.len()
&& value[value_index] != b'/'
&& wildcard_path_matches_from(
pattern,
value,
pattern_index + 1,
value_index + 1,
memo,
stride,
)
}
b'[' => {
if value_index < value.len() && value[value_index] != b'/' {
if let Some((class_matches, next_pattern_index)) =
wildcard_class_matches(pattern, pattern_index, value[value_index])
{
class_matches
&& wildcard_path_matches_from(
pattern,
value,
next_pattern_index,
value_index + 1,
memo,
stride,
)
} else {
value[value_index] == b'['
&& wildcard_path_matches_from(
pattern,
value,
pattern_index + 1,
value_index + 1,
memo,
stride,
)
}
} else {
false
}
}
b'\\' if pattern_index + 1 < pattern.len() => {
value_index < value.len()
&& pattern[pattern_index + 1] == value[value_index]
&& wildcard_path_matches_from(
pattern,
value,
pattern_index + 2,
value_index + 1,
memo,
stride,
)
}
literal => {
value_index < value.len()
&& literal == value[value_index]
&& wildcard_path_matches_from(
pattern,
value,
pattern_index + 1,
value_index + 1,
memo,
stride,
)
}
}
};
memo[cell] = Some(matched);
matched
}
fn wildcard_double_star_matches(
pattern: &[u8],
value: &[u8],
pattern_index: usize,
value_index: usize,
memo: &mut [Option<bool>],
stride: usize,
) -> bool {
let after_stars = pattern_index + 2;
if pattern.get(after_stars) == Some(&b'/') {
if wildcard_path_matches_from(pattern, value, after_stars + 1, value_index, memo, stride) {
return true;
}
for next in value_index..value.len() {
if value[next] == b'/'
&& wildcard_path_matches_from(
pattern,
value,
after_stars + 1,
next + 1,
memo,
stride,
)
{
return true;
}
}
return false;
}
for next in value_index..=value.len() {
if wildcard_path_matches_from(pattern, value, after_stars, next, memo, stride) {
return true;
}
}
false
}
fn wildcard_class_matches(pattern: &[u8], start: usize, value: u8) -> Option<(bool, usize)> {
let mut index = start + 1;
let negated = matches!(pattern.get(index), Some(b'!' | b'^'));
if negated {
index += 1;
}
let class_start = index;
let end = pattern[class_start..]
.iter()
.position(|byte| *byte == b']')
.map(|position| class_start + position)?;
if end == class_start {
return None;
}
let mut matched = false;
while index < end {
if index + 2 < end && pattern[index + 1] == b'-' {
let lower = pattern[index].min(pattern[index + 2]);
let upper = pattern[index].max(pattern[index + 2]);
matched |= lower <= value && value <= upper;
index += 3;
} else {
matched |= pattern[index] == value;
index += 1;
}
}
Some((if negated { !matched } else { matched }, end + 1))
}
#[derive(Debug, Default)]
struct AttributeMatcher {
patterns: Vec<AttributePattern>,
attribute_order: BTreeMap<Vec<u8>, usize>,
macros: BTreeMap<Vec<u8>, Vec<AttributeAssignment>>,
ignore_case: bool,
}
#[derive(Debug)]
struct AttributePattern {
base: Vec<u8>,
pattern: Vec<u8>,
ignore_case_pattern: Option<Vec<u8>>,
anchored: bool,
has_slash: bool,
assignments: Vec<AttributeAssignment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AttributeAssignment {
attribute: Vec<u8>,
state: Option<AttributeState>,
}
impl AttributeMatcher {
fn from_worktree_root(root: &Path) -> Result<Self> {
let mut matcher = Self::default();
let git_dir = root.join(".git");
matcher.configure_case_sensitivity(&git_dir);
if !matcher.read_configured_attributes(root, &git_dir) {
matcher.read_default_global_attributes();
}
collect_attribute_patterns(root, root, &mut matcher)?;
read_attribute_patterns(
git_dir.join("info").join("attributes"),
&mut matcher,
&[],
b".git/info/attributes",
false,
);
Ok(matcher)
}
fn from_worktree_base(root: &Path) -> Self {
let mut matcher = Self::default();
let git_dir = root.join(".git");
matcher.configure_case_sensitivity(&git_dir);
if !matcher.read_configured_attributes(root, &git_dir) {
matcher.read_default_global_attributes();
}
read_attribute_patterns(
git_dir.join("info").join("attributes"),
&mut matcher,
&[],
b".git/info/attributes",
false,
);
matcher
}
fn attributes_for_path(
&self,
path: &[u8],
requested: &[Vec<u8>],
all: bool,
) -> Vec<AttributeCheck> {
let mut states = BTreeMap::<Vec<u8>, Option<AttributeState>>::new();
for pattern in &self.patterns {
if !pattern.matches(path, self.ignore_case) {
continue;
}
for assignment in &pattern.assignments {
self.apply_attribute_assignment(&mut states, assignment);
}
}
if all {
let mut checks = states
.into_iter()
.filter_map(|(attribute, state)| {
state.map(|state| AttributeCheck {
attribute,
state: Some(state),
})
})
.collect::<Vec<_>>();
checks.sort_by(|left, right| {
attribute_all_rank(&left.attribute, &self.attribute_order)
.cmp(&attribute_all_rank(&right.attribute, &self.attribute_order))
.then_with(|| left.attribute.cmp(&right.attribute))
});
return checks;
}
requested
.iter()
.map(|attribute| AttributeCheck {
attribute: attribute.clone(),
state: states.get(attribute).cloned().flatten(),
})
.collect()
}
fn push_attribute_order(&mut self, attribute: &[u8]) {
let next = self.attribute_order.len();
self.attribute_order
.entry(attribute.to_vec())
.or_insert(next);
}
fn apply_attribute_assignment(
&self,
states: &mut BTreeMap<Vec<u8>, Option<AttributeState>>,
assignment: &AttributeAssignment,
) {
let mut stack = vec![assignment.clone()];
let mut expanded = 0usize;
while let Some(assignment) = stack.pop() {
states.insert(assignment.attribute.clone(), assignment.state.clone());
if assignment.state != Some(AttributeState::Set) {
continue;
}
let Some(macro_assignments) = self.macros.get(&assignment.attribute) else {
continue;
};
expanded += 1;
if expanded > 10000 {
break;
}
for macro_assignment in macro_assignments.iter().rev() {
stack.push(macro_assignment.clone());
}
}
}
fn configure_case_sensitivity(&mut self, git_dir: &Path) {
let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
return;
};
self.ignore_case = config.get_bool("core", None, "ignorecase").unwrap_or(false);
}
fn read_configured_attributes(&mut self, root: &Path, git_dir: &Path) -> bool {
let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
return false;
};
let Some(value) = config.get("core", None, "attributesFile") else {
return false;
};
let path = expand_core_excludes_file(root, value);
read_attribute_patterns(path, self, &[], value.as_bytes(), false);
true
}
fn read_default_global_attributes(&mut self) {
if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME")
&& !config_home.is_empty()
{
let path = PathBuf::from(config_home).join("git").join("attributes");
let source = path.to_string_lossy().into_owned();
read_attribute_patterns(path, self, &[], source.as_bytes(), false);
return;
}
if let Some(home) = std::env::var_os("HOME") {
let path = PathBuf::from(home)
.join(".config")
.join("git")
.join("attributes");
let source = path.to_string_lossy().into_owned();
read_attribute_patterns(path, self, &[], source.as_bytes(), false);
}
}
}
fn read_dir_ignore_patterns_for_base(
dir: &Path,
base: &[u8],
matcher: &mut IgnoreMatcher,
) -> Result<()> {
let mut source = base.to_vec();
if !source.is_empty() {
source.push(b'/');
}
source.extend_from_slice(b".gitignore");
read_per_directory_ignore_patterns_into_matcher(dir.join(".gitignore"), matcher, base, &source)
}
fn read_dir_attribute_patterns(
root: &Path,
dir: &Path,
matcher: &mut AttributeMatcher,
) -> Result<()> {
let relative = dir.strip_prefix(root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", dir.display()))
})?;
let base = git_path_bytes(relative)?;
read_dir_attribute_patterns_for_base(dir, &base, matcher)
}
fn read_dir_attribute_patterns_for_base(
dir: &Path,
base: &[u8],
matcher: &mut AttributeMatcher,
) -> Result<()> {
let mut source = base.to_vec();
if !source.is_empty() {
source.push(b'/');
}
source.extend_from_slice(b".gitattributes");
read_attribute_patterns(dir.join(".gitattributes"), matcher, base, &source, true);
Ok(())
}
fn collect_attribute_patterns(
root: &Path,
dir: &Path,
matcher: &mut AttributeMatcher,
) -> Result<()> {
read_dir_attribute_patterns(root, dir, matcher)?;
let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if path.file_name().and_then(|name| name.to_str()) == Some(".git") {
continue;
}
if entry.metadata()?.is_dir() {
collect_attribute_patterns(root, &path, matcher)?;
}
}
Ok(())
}
fn read_attribute_patterns(
path: impl AsRef<Path>,
matcher: &mut AttributeMatcher,
base: &[u8],
source: &[u8],
nofollow: bool,
) {
let path = path.as_ref();
if nofollow
&& let Ok(metadata) = fs::symlink_metadata(path)
&& metadata.file_type().is_symlink()
{
eprintln!(
"warning: unable to access '{}': Too many levels of symbolic links",
String::from_utf8_lossy(source)
);
return;
}
let Ok(contents) = fs::read(path) else {
return;
};
read_attribute_patterns_from_bytes(&contents, matcher, base, source);
}
fn read_attribute_patterns_from_bytes(
contents: &[u8],
matcher: &mut AttributeMatcher,
base: &[u8],
source: &[u8],
) {
for (index, raw) in contents.split(|byte| *byte == b'\n').enumerate() {
if raw.len() >= 2048 {
eprintln!(
"warning: ignoring overly long attributes line {}",
index + 1
);
continue;
}
push_attribute_pattern(matcher, raw, base, source, index + 1);
}
}
fn collect_attribute_patterns_from_tree(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
base: Vec<u8>,
matcher: &mut AttributeMatcher,
) -> Result<()> {
let object = read_expected_object(db, tree_oid, ObjectType::Tree)?;
let mut entries = Tree::parse(format, &object.body)?.entries;
entries.sort_by(|left, right| left.name.cmp(&right.name));
for entry in &entries {
if entry.name == b".gitattributes" && tree_entry_object_type(entry.mode) == ObjectType::Blob
{
let object = db.read_object(&entry.oid).map_err(|err| {
expect_missing_object_kind(err, entry.oid, MissingObjectKind::Blob)
})?;
if object.object_type == ObjectType::Blob {
let source = attribute_source_for_base(&base);
read_attribute_patterns_from_bytes(&object.body, matcher, &base, &source);
}
}
}
for entry in entries {
if tree_entry_object_type(entry.mode) != ObjectType::Tree {
continue;
}
let mut child_base = base.clone();
if !child_base.is_empty() {
child_base.push(b'/');
}
child_base.extend_from_slice(entry.name.as_bytes());
collect_attribute_patterns_from_tree(db, format, &entry.oid, child_base, matcher)?;
}
Ok(())
}
fn collect_attribute_patterns_from_index(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
matcher: &mut AttributeMatcher,
) -> Result<()> {
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(());
}
let mut entries = Index::parse(&fs::read(index_path)?, format)?.entries;
entries.sort_by(|left, right| left.path.cmp(&right.path));
for entry in entries {
let is_attributes_file =
entry.path == b".gitattributes" || entry.path.as_bytes().ends_with(b"/.gitattributes");
if index_entry_stage(&entry) != 0
|| tree_entry_object_type(entry.mode) != ObjectType::Blob
|| !is_attributes_file
{
continue;
}
let base = match entry.path.as_bytes().strip_suffix(b".gitattributes") {
Some(b"") => Vec::new(),
Some(parent) => parent.strip_suffix(b"/").unwrap_or(parent).to_vec(),
None => continue,
};
let object = db
.read_object(&entry.oid)
.map_err(|err| expect_missing_object_kind(err, entry.oid, MissingObjectKind::Blob))?;
if object.object_type == ObjectType::Blob {
read_attribute_patterns_from_bytes(&object.body, matcher, &base, entry.path.as_bytes());
}
}
Ok(())
}
fn attribute_source_for_base(base: &[u8]) -> Vec<u8> {
let mut source = base.to_vec();
if !source.is_empty() {
source.push(b'/');
}
source.extend_from_slice(b".gitattributes");
source
}
fn push_attribute_pattern(
matcher: &mut AttributeMatcher,
raw: &[u8],
base: &[u8],
source: &[u8],
line_number: usize,
) {
let line = raw.strip_suffix(b"\r").unwrap_or(raw);
let line = trim_ascii_whitespace(line);
if line.is_empty() || line.starts_with(b"#") {
return;
}
let Some((raw_pattern, fields)) = split_attribute_line(line) else {
return;
};
if let Some(macro_name) = raw_pattern.strip_prefix(b"[attr]") {
if macro_name.is_empty() {
return;
}
if is_reserved_attribute_name(macro_name) {
report_invalid_attribute_name(macro_name, source, line_number);
return;
}
let mut assignments = Vec::new();
for field in fields {
push_attribute_assignments(
&mut assignments,
&field,
&matcher.macros,
source,
line_number,
);
}
matcher.push_attribute_order(macro_name);
for assignment in &assignments {
matcher.push_attribute_order(&assignment.attribute);
}
matcher.macros.insert(macro_name.to_vec(), assignments);
return;
}
let mut assignments = Vec::new();
for field in fields {
push_attribute_assignments(
&mut assignments,
&field,
&matcher.macros,
source,
line_number,
);
}
if assignments.is_empty() {
return;
}
for assignment in &assignments {
matcher.push_attribute_order(&assignment.attribute);
}
if raw_pattern.starts_with(b"!") {
eprintln!(
"warning: Negative patterns are ignored in git attributes\nUse '\\!' for literal leading exclamation."
);
return;
}
let raw_pattern = raw_pattern
.strip_prefix(br"\!")
.map(|pattern| {
let mut literal = Vec::with_capacity(pattern.len() + 1);
literal.push(b'!');
literal.extend_from_slice(pattern);
literal
})
.unwrap_or(raw_pattern);
let (anchored, pattern) = if let Some(pattern) = raw_pattern.strip_prefix(b"/") {
(true, pattern)
} else {
(false, raw_pattern.as_slice())
};
if pattern.is_empty() {
return;
}
matcher.patterns.push(AttributePattern {
base: base.to_vec(),
pattern: pattern.to_vec(),
ignore_case_pattern: matcher.ignore_case.then(|| ascii_lowercase(pattern)),
anchored,
has_slash: pattern.contains(&b'/'),
assignments,
});
}
fn push_attribute_assignments(
assignments: &mut Vec<AttributeAssignment>,
field: &[u8],
macros: &BTreeMap<Vec<u8>, Vec<AttributeAssignment>>,
source: &[u8],
line_number: usize,
) {
if let Some(macro_assignments) = macros.get(field) {
assignments.push(AttributeAssignment {
attribute: field.to_vec(),
state: Some(AttributeState::Set),
});
assignments.extend(macro_assignments.iter().cloned());
return;
}
if field == b"binary" {
assignments.push(AttributeAssignment {
attribute: b"binary".to_vec(),
state: Some(AttributeState::Set),
});
assignments.push(AttributeAssignment {
attribute: b"diff".to_vec(),
state: Some(AttributeState::Unset),
});
assignments.push(AttributeAssignment {
attribute: b"merge".to_vec(),
state: Some(AttributeState::Unset),
});
assignments.push(AttributeAssignment {
attribute: b"text".to_vec(),
state: Some(AttributeState::Unset),
});
return;
}
if let Some(attribute) = field.strip_prefix(b"-") {
if !attribute.is_empty() {
if is_reserved_attribute_name(attribute) {
report_invalid_attribute_name(attribute, source, line_number);
return;
}
assignments.push(AttributeAssignment {
attribute: attribute.to_vec(),
state: Some(AttributeState::Unset),
});
}
return;
}
if let Some(attribute) = field.strip_prefix(b"!") {
if !attribute.is_empty() {
if is_reserved_attribute_name(attribute) {
report_invalid_attribute_name(attribute, source, line_number);
return;
}
assignments.push(AttributeAssignment {
attribute: attribute.to_vec(),
state: None,
});
}
return;
}
if let Some(equal) = field.iter().position(|byte| *byte == b'=') {
let attribute = &field[..equal];
let value = &field[equal + 1..];
if !attribute.is_empty() {
if is_reserved_attribute_name(attribute) {
report_invalid_attribute_name(attribute, source, line_number);
return;
}
assignments.push(AttributeAssignment {
attribute: attribute.to_vec(),
state: Some(AttributeState::Value(value.to_vec())),
});
}
return;
}
if is_reserved_attribute_name(field) {
report_invalid_attribute_name(field, source, line_number);
return;
}
assignments.push(AttributeAssignment {
attribute: field.to_vec(),
state: Some(AttributeState::Set),
});
}
fn split_attribute_line(line: &[u8]) -> Option<(Vec<u8>, Vec<Vec<u8>>)> {
let mut index = 0;
while line.get(index).is_some_and(u8::is_ascii_whitespace) {
index += 1;
}
if index == line.len() || line[index] == b'#' {
return None;
}
let pattern = if line[index] == b'"' {
match c_unquote_prefix(&line[index..]) {
Some((pattern, consumed)) => {
index += consumed;
pattern
}
None => {
let start = index;
while index < line.len() && !line[index].is_ascii_whitespace() {
index += 1;
}
line[start..index].to_vec()
}
}
} else {
let start = index;
while index < line.len() && !line[index].is_ascii_whitespace() {
index += 1;
}
line[start..index].to_vec()
};
let fields = line[index..]
.split(|byte| byte.is_ascii_whitespace())
.filter(|field| !field.is_empty())
.map(Vec::from)
.collect();
Some((pattern, fields))
}
fn c_unquote_prefix(input: &[u8]) -> Option<(Vec<u8>, usize)> {
if input.first() != Some(&b'"') {
return None;
}
let mut out = Vec::new();
let mut index = 1;
while index < input.len() {
match input[index] {
b'"' => return Some((out, index + 1)),
b'\\' if index + 1 < input.len() => {
index += 1;
let byte = match input[index] {
b'a' => 0x07,
b'b' => 0x08,
b'f' => 0x0c,
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'v' => 0x0b,
other => other,
};
out.push(byte);
}
byte => out.push(byte),
}
index += 1;
}
None
}
fn is_reserved_attribute_name(attribute: &[u8]) -> bool {
attribute.starts_with(b"builtin_")
}
fn report_invalid_attribute_name(attribute: &[u8], source: &[u8], line_number: usize) {
eprintln!(
"{} is not a valid attribute name: {}:{}",
String::from_utf8_lossy(attribute),
String::from_utf8_lossy(source),
line_number
);
}
fn attribute_all_rank(
attribute: &[u8],
order: &BTreeMap<Vec<u8>, usize>,
) -> (usize, usize, Vec<u8>) {
let rank = match attribute {
b"binary" => 0,
b"diff" => 1,
b"merge" => 2,
b"text" => 3,
b"eol" => 5,
_ => 4,
};
let order = order.get(attribute).copied().unwrap_or(usize::MAX);
(rank, order, attribute.to_vec())
}
fn trim_ascii_whitespace(mut value: &[u8]) -> &[u8] {
while value.first().is_some_and(u8::is_ascii_whitespace) {
value = &value[1..];
}
while value.last().is_some_and(u8::is_ascii_whitespace) {
value = &value[..value.len() - 1];
}
value
}
impl AttributePattern {
fn matches(&self, path: &[u8], ignore_case: bool) -> bool {
let path = if self.base.is_empty() {
path
} else {
match strip_attribute_base(path, &self.base, ignore_case) {
Some(rest) => rest,
None => return false,
}
};
let folded_pattern;
let folded_path;
let (pattern_ref, path_ref) = if ignore_case {
folded_path = ascii_lowercase(path);
let pattern_ref = if let Some(pattern) = self.ignore_case_pattern.as_deref() {
pattern
} else {
folded_pattern = ascii_lowercase(&self.pattern);
folded_pattern.as_slice()
};
(pattern_ref, folded_path.as_slice())
} else {
(self.pattern.as_slice(), path)
};
if self.anchored || self.has_slash {
return wildcard_path_matches(pattern_ref, path_ref);
}
path_ref
.rsplit(|byte| *byte == b'/')
.next()
.is_some_and(|basename| wildcard_path_matches(pattern_ref, basename))
}
}
fn strip_attribute_base<'a>(path: &'a [u8], base: &[u8], ignore_case: bool) -> Option<&'a [u8]> {
if path.len() <= base.len() || path.get(base.len()) != Some(&b'/') {
return None;
}
let prefix = &path[..base.len()];
let matches = if ignore_case {
prefix.eq_ignore_ascii_case(base)
} else {
prefix == base
};
matches.then_some(&path[base.len() + 1..])
}
fn ascii_lowercase(value: &[u8]) -> Vec<u8> {
value.iter().map(u8::to_ascii_lowercase).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EolConversion {
None,
Lf,
Crlf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextDecision {
Binary,
Text,
Auto,
Unspecified,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ContentFilterPlan {
text: TextDecision,
eol: EolConversion,
ident: bool,
driver: Option<FilterDriver>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FilterDriver {
name: Vec<u8>,
process: Option<String>,
clean: Option<String>,
smudge: Option<String>,
required: bool,
}
fn decode_crlf_family_attribute(state: Option<&AttributeState>) -> (TextDecision, EolConversion) {
match state {
Some(AttributeState::Set) => (TextDecision::Text, EolConversion::None),
Some(AttributeState::Unset) => (TextDecision::Binary, EolConversion::None),
Some(AttributeState::Value(value)) if value == b"auto" => {
(TextDecision::Auto, EolConversion::None)
}
Some(AttributeState::Value(value)) if value == b"input" => {
(TextDecision::Text, EolConversion::Lf)
}
_ => (TextDecision::Unspecified, EolConversion::None),
}
}
impl ContentFilterPlan {
fn resolve(config: &GitConfig, checks: &[AttributeCheck]) -> Self {
let text_attr = checks.iter().find(|check| check.attribute == b"text");
let crlf_attr = checks.iter().find(|check| check.attribute == b"crlf");
let ident_attr = checks.iter().find(|check| check.attribute == b"ident");
let eol_attr = checks.iter().find(|check| check.attribute == b"eol");
let filter_attr = checks.iter().find(|check| check.attribute == b"filter");
let eol_value = eol_attr.and_then(|check| match &check.state {
Some(AttributeState::Value(value)) => Some(value.clone()),
_ => None,
});
let mut forced_eol = EolConversion::None;
let mut text = match text_attr.map(|check| &check.state) {
Some(Some(AttributeState::Set)) => TextDecision::Text,
Some(Some(AttributeState::Unset)) => TextDecision::Binary,
Some(Some(AttributeState::Value(value))) if value == b"auto" => TextDecision::Auto,
Some(Some(AttributeState::Value(value))) if value == b"input" => {
forced_eol = EolConversion::Lf;
TextDecision::Text
}
Some(Some(AttributeState::Value(_))) => TextDecision::Text,
_ => {
let (decision, eol) =
decode_crlf_family_attribute(crlf_attr.and_then(|check| check.state.as_ref()));
forced_eol = eol;
decision
}
};
let eol = match (&text, eol_value.as_deref()) {
(TextDecision::Binary, _) => EolConversion::None,
(_, Some(b"crlf")) => {
if text == TextDecision::Unspecified {
text = TextDecision::Text;
}
EolConversion::Crlf
}
(_, Some(b"lf")) => {
if text == TextDecision::Unspecified {
text = TextDecision::Text;
}
EolConversion::Lf
}
_ if forced_eol == EolConversion::Lf => EolConversion::Lf,
_ => eol_from_config(config),
};
let eol = match (&text, eol) {
(TextDecision::Text | TextDecision::Auto, EolConversion::None) => EolConversion::Lf,
(_, eol) => eol,
};
let text = match (text, eol_attr.is_some()) {
(TextDecision::Unspecified, _) => {
if autocrlf_enabled(config) {
TextDecision::Auto
} else {
TextDecision::Unspecified
}
}
(text, _) => text,
};
let driver = resolve_filter_driver(config, filter_attr);
let ident = matches!(
ident_attr.and_then(|check| check.state.as_ref()),
Some(AttributeState::Set)
);
ContentFilterPlan {
text,
eol,
ident,
driver,
}
}
fn convert_eol(&self, content: &[u8]) -> bool {
match self.text {
TextDecision::Binary | TextDecision::Unspecified => false,
TextDecision::Text => self.eol != EolConversion::None,
TextDecision::Auto => self.eol != EolConversion::None && !looks_binary(content),
}
}
fn will_convert_lf_to_crlf(&self, content: &[u8]) -> bool {
self.will_convert_lf_to_crlf_stats(&gather_convert_stats(content))
}
fn will_convert_lf_to_crlf_stats(&self, stats: &ConvertStats) -> bool {
if self.eol != EolConversion::Crlf {
return false;
}
if stats.lonelf == 0 {
return false;
}
if self.text == TextDecision::Auto {
if stats.lonecr > 0 || stats.crlf > 0 {
return false;
}
if convert_is_binary(stats) {
return false;
}
}
true
}
fn safecrlf_applies(&self) -> bool {
matches!(self.text, TextDecision::Text | TextDecision::Auto)
}
fn check_safe_crlf_stats(
&self,
old_stats: &ConvertStats,
index_has_crlf: bool,
flags: ConvFlags,
path: &[u8],
) -> Result<()> {
if flags == ConvFlags::Off || !self.safecrlf_applies() {
return Ok(());
}
let mut convert_crlf_into_lf = old_stats.crlf > 0;
if self.text == TextDecision::Auto {
if convert_is_binary(old_stats) {
return Ok(());
}
if index_has_crlf {
convert_crlf_into_lf = false;
}
}
let mut new_stats = old_stats.clone();
if convert_crlf_into_lf {
new_stats.lonelf += new_stats.crlf;
new_stats.crlf = 0;
}
if self.will_convert_lf_to_crlf_stats(&new_stats) {
new_stats.crlf += new_stats.lonelf;
new_stats.lonelf = 0;
}
check_safe_crlf(old_stats, &new_stats, flags, path)
}
}
fn eol_from_config(config: &GitConfig) -> EolConversion {
if let Some(value) = config.get("core", None, "autocrlf") {
match value.to_ascii_lowercase().as_str() {
"input" => return EolConversion::Lf,
"true" | "yes" | "on" | "1" => return EolConversion::Crlf,
_ => {}
}
}
if config.get_bool("core", None, "autocrlf") == Some(true) {
return EolConversion::Crlf;
}
match config
.get("core", None, "eol")
.map(|v| v.to_ascii_lowercase())
{
Some(ref v) if v == "crlf" => EolConversion::Crlf,
Some(ref v) if v == "lf" => EolConversion::Lf,
_ => EolConversion::None,
}
}
fn autocrlf_enabled(config: &GitConfig) -> bool {
if let Some(value) = config.get("core", None, "autocrlf")
&& value.eq_ignore_ascii_case("input")
{
return true;
}
config.get_bool("core", None, "autocrlf") == Some(true)
}
fn resolve_filter_driver(
config: &GitConfig,
filter_attr: Option<&AttributeCheck>,
) -> Option<FilterDriver> {
let name = match filter_attr.map(|check| &check.state) {
Some(Some(AttributeState::Value(value))) => value.clone(),
_ => return None,
};
let subsection = String::from_utf8_lossy(&name).into_owned();
let process = filter_config_value(config, &subsection, "process").filter(|cmd| !cmd.is_empty());
let clean = filter_config_value(config, &subsection, "clean").filter(|cmd| !cmd.is_empty());
let smudge = filter_config_value(config, &subsection, "smudge").filter(|cmd| !cmd.is_empty());
let required = filter_config_bool(config, &subsection, "required").unwrap_or(false);
if process.is_none() && clean.is_none() && smudge.is_none() && !required {
return None;
}
Some(FilterDriver {
name,
process,
clean,
smudge,
required,
})
}
fn filter_config_value(config: &GitConfig, subsection: &str, key: &str) -> Option<String> {
config
.get("filter", Some(subsection), key)
.map(str::to_owned)
.or_else(|| global_filter_config_value(subsection, key))
}
fn filter_config_bool(config: &GitConfig, subsection: &str, key: &str) -> Option<bool> {
config
.get_bool("filter", Some(subsection), key)
.or_else(|| {
global_filter_config_value(subsection, key)
.as_deref()
.and_then(sley_config::parse_config_bool)
})
}
fn global_filter_config_value(subsection: &str, key: &str) -> Option<String> {
for (path, _) in sley_config::default_config_layer_paths().into_iter().rev() {
let Ok(config) = GitConfig::read(path) else {
continue;
};
if let Some(value) = config.get("filter", Some(subsection), key) {
return Some(value.to_owned());
}
}
None
}
fn looks_binary(content: &[u8]) -> bool {
const FIRST_FEW_BYTES: usize = 8000;
let window = &content[..content.len().min(FIRST_FEW_BYTES)];
window.contains(&0)
}
fn convert_crlf_to_lf_cow(content: Cow<'_, [u8]>) -> Cow<'_, [u8]> {
if !content.windows(2).any(|window| window == b"\r\n") {
return content;
}
let mut out = Vec::with_capacity(content.len());
let mut index = 0;
while index < content.len() {
let byte = content[index];
if byte == b'\r' && content.get(index + 1) == Some(&b'\n') {
index += 1;
continue;
}
out.push(byte);
index += 1;
}
Cow::Owned(out)
}
fn convert_lf_to_crlf(content: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(content.len() + content.len() / 16);
let mut prev = 0u8;
for &byte in content {
if byte == b'\n' && prev != b'\r' {
out.push(b'\r');
}
out.push(byte);
prev = byte;
}
out
}
fn ident_to_git_cow(content: Cow<'_, [u8]>) -> Cow<'_, [u8]> {
let input = content.as_ref();
if !has_git_ident(input) {
return content;
}
let mut out = Vec::with_capacity(input.len());
let mut pos = 0;
while let Some(relative) = input[pos..].iter().position(|byte| *byte == b'$') {
let dollar = pos + relative;
out.extend_from_slice(&input[pos..=dollar]);
pos = dollar + 1;
if input.len().saturating_sub(pos) > 3 && input[pos..].starts_with(b"Id:") {
let search = &input[pos + 3..];
let Some(end_relative) = search.iter().position(|byte| *byte == b'$') else {
break;
};
let end = pos + 3 + end_relative;
if input[pos + 3..end].contains(&b'\n') {
continue;
}
out.extend_from_slice(b"Id$");
pos = end + 1;
}
}
out.extend_from_slice(&input[pos..]);
Cow::Owned(out)
}
fn ident_to_worktree_cow(format: ObjectFormat, content: Cow<'_, [u8]>) -> Result<Cow<'_, [u8]>> {
let input = content.as_ref();
if !has_git_ident(input) {
return Ok(content);
}
let oid = EncodedObject::new(ObjectType::Blob, input.to_vec()).object_id(format)?;
let replacement = format!("Id: {} $", oid.to_hex());
let mut out = Vec::with_capacity(input.len() + replacement.len());
let mut pos = 0;
while let Some(relative) = input[pos..].iter().position(|byte| *byte == b'$') {
let dollar = pos + relative;
out.extend_from_slice(&input[pos..=dollar]);
pos = dollar + 1;
if input.len().saturating_sub(pos) < 3 || !input[pos..].starts_with(b"Id") {
continue;
}
match input.get(pos + 2) {
Some(b'$') => {
pos += 3;
}
Some(b':') => {
let search = &input[pos + 3..];
let Some(end_relative) = search.iter().position(|byte| *byte == b'$') else {
break;
};
let end = pos + 3 + end_relative;
if input[pos + 3..end].contains(&b'\n') || is_foreign_ident(&input[pos + 3..end]) {
continue;
}
pos = end + 1;
}
_ => continue,
}
out.extend_from_slice(replacement.as_bytes());
}
out.extend_from_slice(&input[pos..]);
Ok(Cow::Owned(out))
}
fn has_git_ident(content: &[u8]) -> bool {
let mut pos = 0;
while let Some(relative) = content[pos..].iter().position(|byte| *byte == b'$') {
let start = pos + relative + 1;
if content.len().saturating_sub(start) < 3 {
break;
}
if !content[start..].starts_with(b"Id") {
pos = start;
continue;
}
match content.get(start + 2) {
Some(b'$') => return true,
Some(b':') => {
let search = &content[start + 3..];
let Some(end_relative) = search.iter().position(|byte| *byte == b'$') else {
break;
};
let end = start + 3 + end_relative;
if !content[start + 3..end].contains(&b'\n') {
return true;
}
pos = end + 1;
}
_ => pos = start,
}
}
false
}
fn is_foreign_ident(expansion: &[u8]) -> bool {
if expansion.len() <= 1 {
return false;
}
expansion[1..expansion.len().saturating_sub(1)].contains(&b' ')
}
fn run_filter_command(command: &str, path: &[u8], content: &[u8]) -> Result<Vec<u8>> {
let display_path = String::from_utf8_lossy(path);
let expanded = command.replace("%f", &shell_quote(&display_path));
let (shell, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("/bin/sh", "-c")
};
let mut child = Command::new(shell)
.arg(flag)
.arg(&expanded)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| GitError::Command(format!("failed to spawn filter `{command}`: {err}")))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| GitError::Command(format!("filter `{command}` stdin unavailable")))?;
let payload = content.to_vec();
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(&payload);
});
let output = child
.wait_with_output()
.map_err(|err| GitError::Command(format!("filter `{command}` failed: {err}")))?;
let _ = writer.join();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitError::Command(format!(
"filter `{command}` exited with {}: {}",
output.status,
stderr.trim()
)));
}
Ok(output.stdout)
}
const PROCESS_CAP_CLEAN: u8 = 1;
const PROCESS_CAP_SMUDGE: u8 = 1 << 1;
const PROCESS_CAP_DELAY: u8 = 1 << 2;
const PKT_DATA_MAX: usize = 65_516;
static PROCESS_FILTERS: OnceLock<Mutex<HashMap<String, ProcessFilter>>> = OnceLock::new();
type ProcessFilterMetadata = Vec<(String, String)>;
static PROCESS_FILTER_METADATA: OnceLock<Mutex<Option<ProcessFilterMetadata>>> = OnceLock::new();
struct ProcessFilterMetadataGuard {
previous: Option<ProcessFilterMetadata>,
}
impl Drop for ProcessFilterMetadataGuard {
fn drop(&mut self) {
if let Ok(mut guard) = PROCESS_FILTER_METADATA
.get_or_init(|| Mutex::new(None))
.lock()
{
*guard = self.previous.take();
}
}
}
fn set_process_filter_metadata(
metadata: Option<ProcessFilterMetadata>,
) -> ProcessFilterMetadataGuard {
let mutex = PROCESS_FILTER_METADATA.get_or_init(|| Mutex::new(None));
let previous = mutex
.lock()
.map(|mut guard| std::mem::replace(&mut *guard, metadata))
.unwrap_or(None);
ProcessFilterMetadataGuard { previous }
}
fn current_process_filter_metadata() -> Option<ProcessFilterMetadata> {
PROCESS_FILTER_METADATA
.get_or_init(|| Mutex::new(None))
.lock()
.ok()
.and_then(|guard| guard.clone())
}
struct ProcessFilter {
child: Child,
stdin: ChildStdin,
stdout: ChildStdout,
capabilities: u8,
}
enum ProcessFilterOutcome {
Filtered(Vec<u8>),
Unsupported,
Status(String),
}
struct ProcessFilterFailure {
message: String,
protocol: bool,
}
impl ProcessFilterFailure {
fn protocol(message: impl Into<String>) -> Self {
Self {
message: message.into(),
protocol: true,
}
}
}
fn run_process_filter(
command: &str,
direction: &str,
path: &[u8],
content: &[u8],
blob: Option<ObjectId>,
) -> std::result::Result<ProcessFilterOutcome, ProcessFilterFailure> {
let filters = PROCESS_FILTERS.get_or_init(|| Mutex::new(HashMap::new()));
let mut filters = filters
.lock()
.map_err(|_| ProcessFilterFailure::protocol("process filter cache poisoned"))?;
if !filters.contains_key(command) {
let filter = ProcessFilter::start(command)?;
filters.insert(command.to_string(), filter);
}
let result = filters
.get_mut(command)
.expect("process filter was inserted")
.apply(direction, path, content, blob);
if result.as_ref().is_err_and(|err| err.protocol) {
filters.remove(command);
}
result
}
impl ProcessFilter {
fn start(command: &str) -> std::result::Result<Self, ProcessFilterFailure> {
let (shell, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("/bin/sh", "-c")
};
let mut child = Command::new(shell)
.arg(flag)
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|err| {
ProcessFilterFailure::protocol(format!(
"cannot fork to run subprocess '{command}': {err}"
))
})?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| ProcessFilterFailure::protocol("process filter stdin unavailable"))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| ProcessFilterFailure::protocol("process filter stdout unavailable"))?;
write_pkt_text(&mut stdin, "git-filter-client\n")?;
write_pkt_text(&mut stdin, "version=2\n")?;
write_flush(&mut stdin)?;
let line = read_pkt_text(&mut stdout)?.ok_or_else(|| {
ProcessFilterFailure::protocol(
"Unexpected line '<flush packet>', expected git-filter-server",
)
})?;
if line != "git-filter-server" {
return Err(ProcessFilterFailure::protocol(format!(
"Unexpected line '{line}', expected git-filter-server"
)));
}
let line = read_pkt_text(&mut stdout)?.ok_or_else(|| {
ProcessFilterFailure::protocol("Unexpected line '<flush packet>', expected version")
})?;
if line != "version=2" {
return Err(ProcessFilterFailure::protocol(format!(
"Unexpected line '{line}', expected version"
)));
}
if let Some(line) = read_pkt_text(&mut stdout)? {
return Err(ProcessFilterFailure::protocol(format!(
"Unexpected line '{line}', expected flush"
)));
}
write_pkt_text(&mut stdin, "capability=clean\n")?;
write_pkt_text(&mut stdin, "capability=smudge\n")?;
write_pkt_text(&mut stdin, "capability=delay\n")?;
write_flush(&mut stdin)?;
let mut capabilities = 0;
while let Some(line) = read_pkt_text(&mut stdout)? {
match line.as_str() {
"capability=clean" => capabilities |= PROCESS_CAP_CLEAN,
"capability=smudge" => capabilities |= PROCESS_CAP_SMUDGE,
"capability=delay" => capabilities |= PROCESS_CAP_DELAY,
_ => {}
}
}
Ok(Self {
child,
stdin,
stdout,
capabilities,
})
}
fn apply(
&mut self,
direction: &str,
path: &[u8],
content: &[u8],
blob: Option<ObjectId>,
) -> std::result::Result<ProcessFilterOutcome, ProcessFilterFailure> {
let wanted = match direction {
"clean" => PROCESS_CAP_CLEAN,
"smudge" => PROCESS_CAP_SMUDGE,
_ => 0,
};
if self.capabilities & wanted == 0 {
return Ok(ProcessFilterOutcome::Unsupported);
}
write_pkt_text(&mut self.stdin, &format!("command={direction}\n"))?;
write_pkt_text(
&mut self.stdin,
&format!("pathname={}\n", String::from_utf8_lossy(path)),
)?;
if direction == "smudge"
&& let Some(blob) = blob
{
if let Some(metadata) = current_process_filter_metadata() {
for (key, value) in metadata {
write_pkt_text(&mut self.stdin, &format!("{key}={value}\n"))?;
}
}
write_pkt_text(&mut self.stdin, &format!("blob={}\n", blob.to_hex()))?;
}
write_flush(&mut self.stdin)?;
write_pkt_content(&mut self.stdin, content)?;
write_flush(&mut self.stdin)?;
let mut status = read_process_status(&mut self.stdout)?.unwrap_or_default();
match status.as_str() {
"success" => {}
"error" | "abort" | "delayed" => return Ok(ProcessFilterOutcome::Status(status)),
other => {
return Err(ProcessFilterFailure::protocol(format!(
"external filter returned unsupported status '{other}'"
)));
}
}
let output = read_pkt_content(&mut self.stdout)?;
if let Some(next) = read_process_status(&mut self.stdout)? {
status = next;
}
match status.as_str() {
"" | "success" => Ok(ProcessFilterOutcome::Filtered(output)),
"error" | "abort" | "delayed" => Ok(ProcessFilterOutcome::Status(status)),
other => Err(ProcessFilterFailure::protocol(format!(
"external filter returned unsupported status '{other}'"
))),
}
}
}
impl Drop for ProcessFilter {
fn drop(&mut self) {
let _ = self.stdin.flush();
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn write_pkt_text(
writer: &mut ChildStdin,
text: &str,
) -> std::result::Result<(), ProcessFilterFailure> {
write_pkt_data(writer, text.as_bytes())
}
fn write_pkt_content(
writer: &mut ChildStdin,
content: &[u8],
) -> std::result::Result<(), ProcessFilterFailure> {
for chunk in content.chunks(PKT_DATA_MAX) {
write_pkt_data(writer, chunk)?;
}
Ok(())
}
fn write_pkt_data(
writer: &mut ChildStdin,
data: &[u8],
) -> std::result::Result<(), ProcessFilterFailure> {
let len = data.len() + 4;
write!(writer, "{len:04x}")
.and_then(|_| writer.write_all(data))
.map_err(|err| {
ProcessFilterFailure::protocol(format!("process filter write failed: {err}"))
})
}
fn write_flush(writer: &mut ChildStdin) -> std::result::Result<(), ProcessFilterFailure> {
writer
.write_all(b"0000")
.and_then(|_| writer.flush())
.map_err(|err| {
ProcessFilterFailure::protocol(format!("process filter write failed: {err}"))
})
}
fn read_pkt_text(
reader: &mut ChildStdout,
) -> std::result::Result<Option<String>, ProcessFilterFailure> {
let Some(mut data) = read_pkt_data(reader)? else {
return Ok(None);
};
if data.last() == Some(&b'\n') {
data.pop();
}
Ok(Some(String::from_utf8_lossy(&data).into_owned()))
}
fn read_pkt_content(
reader: &mut ChildStdout,
) -> std::result::Result<Vec<u8>, ProcessFilterFailure> {
let mut out = Vec::new();
while let Some(data) = read_pkt_data(reader)? {
out.extend_from_slice(&data);
}
Ok(out)
}
fn read_pkt_data(
reader: &mut ChildStdout,
) -> std::result::Result<Option<Vec<u8>>, ProcessFilterFailure> {
let mut header = [0u8; 4];
reader.read_exact(&mut header).map_err(|err| {
ProcessFilterFailure::protocol(format!("process filter read failed: {err}"))
})?;
let header = std::str::from_utf8(&header)
.map_err(|err| ProcessFilterFailure::protocol(format!("invalid pkt-line header: {err}")))?;
let len = usize::from_str_radix(header, 16)
.map_err(|err| ProcessFilterFailure::protocol(format!("invalid pkt-line length: {err}")))?;
if len == 0 {
return Ok(None);
}
if len < 4 {
return Err(ProcessFilterFailure::protocol(format!(
"invalid pkt-line length {len}"
)));
}
let mut data = vec![0; len - 4];
reader.read_exact(&mut data).map_err(|err| {
ProcessFilterFailure::protocol(format!("process filter read failed: {err}"))
})?;
Ok(Some(data))
}
fn read_process_status(
reader: &mut ChildStdout,
) -> std::result::Result<Option<String>, ProcessFilterFailure> {
let mut status = None;
while let Some(line) = read_pkt_text(reader)? {
if let Some(value) = line.strip_prefix("status=") {
status = Some(value.to_string());
}
}
Ok(status)
}
fn shell_quote(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('\'');
for ch in value.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
pub fn apply_clean_filter(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
config: &GitConfig,
path: &[u8],
content: &[u8],
) -> Result<Vec<u8>> {
let _ = git_dir.as_ref();
let checks = filter_attribute_checks(worktree_root.as_ref(), path)?;
apply_clean_filter_with_attributes(config, &checks, path, content)
}
pub struct WorktreeAttributes {
matcher: AttributeMatcher,
}
impl WorktreeAttributes {
pub fn from_worktree_root(worktree_root: impl AsRef<Path>) -> Result<Self> {
Ok(Self {
matcher: AttributeMatcher::from_worktree_root(worktree_root.as_ref())?,
})
}
pub fn apply_clean_filter(
&self,
config: &GitConfig,
path: &[u8],
content: &[u8],
) -> Result<Vec<u8>> {
let checks = self
.matcher
.attributes_for_path(path, &filter_attribute_names(), false);
apply_clean_filter_with_attributes(config, &checks, path, content)
}
}
pub struct TreeAttributes {
matcher: AttributeMatcher,
}
impl TreeAttributes {
pub fn from_tree(
attr_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
) -> Result<Self> {
let attr_root = attr_root.as_ref();
let git_dir = git_dir.as_ref();
let mut matcher = AttributeMatcher::default();
matcher.configure_case_sensitivity(git_dir);
if !matcher.read_configured_attributes(attr_root, git_dir) {
matcher.read_default_global_attributes();
}
collect_attribute_patterns_from_tree(db, format, tree_oid, Vec::new(), &mut matcher)?;
read_attribute_patterns(
git_dir.join("info").join("attributes"),
&mut matcher,
&[],
b"info/attributes",
false,
);
Ok(Self { matcher })
}
pub fn apply_smudge_filter(
&self,
config: &GitConfig,
path: &[u8],
content: &[u8],
) -> Result<Vec<u8>> {
let checks = self
.matcher
.attributes_for_path(path, &filter_attribute_names(), false);
apply_smudge_filter_with_attributes(config, &checks, path, content)
}
pub fn attributes_for_path(&self, path: &[u8], requested: &[Vec<u8>]) -> Vec<AttributeCheck> {
self.matcher.attributes_for_path(path, requested, false)
}
pub fn export_subst_for_path(&self, path: &[u8]) -> bool {
self.attribute_is_set(path, b"export-subst")
}
pub fn export_ignore_for_path(&self, path: &[u8]) -> bool {
self.attribute_is_set(path, b"export-ignore")
}
fn attribute_is_set(&self, path: &[u8], attribute: &[u8]) -> bool {
let requested = [attribute.to_vec()];
let checks = self.matcher.attributes_for_path(path, &requested, false);
matches!(
checks.first().and_then(|check| check.state.as_ref()),
Some(AttributeState::Set)
)
}
pub fn diff_attribute_for_path(&self, path: &[u8]) -> Option<AttributeState> {
let requested = [b"diff".to_vec()];
let checks = self.matcher.attributes_for_path(path, &requested, false);
checks.into_iter().next().and_then(|check| check.state)
}
}
pub fn apply_clean_filter_with_attributes(
config: &GitConfig,
attributes: &[AttributeCheck],
path: &[u8],
content: &[u8],
) -> Result<Vec<u8>> {
Ok(apply_clean_filter_with_attributes_cow(config, attributes, path, content)?.into_owned())
}
pub fn apply_clean_filter_with_attributes_cow<'a>(
config: &GitConfig,
attributes: &[AttributeCheck],
path: &[u8],
content: &'a [u8],
) -> Result<Cow<'a, [u8]>> {
apply_clean_filter_with_attributes_cow_safecrlf(
config,
attributes,
path,
content,
ConvFlags::Off,
SafeCrlfIndexBlob::None,
)
}
pub enum SafeCrlfIndexBlob<'a> {
None,
Lookup {
odb: &'a FileObjectDatabase,
oid: ObjectId,
},
}
impl SafeCrlfIndexBlob<'_> {
fn has_crlf(&self) -> bool {
match self {
SafeCrlfIndexBlob::None => false,
SafeCrlfIndexBlob::Lookup { odb, oid } => has_crlf_in_index(odb, oid),
}
}
}
pub fn apply_clean_filter_with_attributes_cow_safecrlf<'a>(
config: &GitConfig,
attributes: &[AttributeCheck],
path: &[u8],
content: &'a [u8],
flags: ConvFlags,
index_blob: SafeCrlfIndexBlob<'_>,
) -> Result<Cow<'a, [u8]>> {
let plan = ContentFilterPlan::resolve(config, attributes);
let mut data = Cow::Borrowed(content);
if let Some(driver) = &plan.driver {
data = run_driver(driver, driver.clean.as_deref(), "clean", None, path, data)?;
}
if flags != ConvFlags::Off && !data.is_empty() && plan.safecrlf_applies() {
let old_stats = gather_convert_stats(&data);
plan.check_safe_crlf_stats(&old_stats, index_blob.has_crlf(), flags, path)?;
}
if plan.convert_eol(&data) {
data = convert_crlf_to_lf_cow(data);
}
if plan.ident {
data = ident_to_git_cow(data);
}
Ok(data)
}
pub fn apply_smudge_filter(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
config: &GitConfig,
path: &[u8],
content: &[u8],
) -> Result<Vec<u8>> {
let checks =
smudge_attribute_checks_from_index(worktree_root.as_ref(), git_dir.as_ref(), format, path)?;
Ok(
apply_smudge_filter_with_attributes_cow_format(config, &checks, path, content, format)?
.into_owned(),
)
}
pub fn apply_smudge_filter_with_attributes(
config: &GitConfig,
attributes: &[AttributeCheck],
path: &[u8],
content: &[u8],
) -> Result<Vec<u8>> {
Ok(apply_smudge_filter_with_attributes_cow(config, attributes, path, content)?.into_owned())
}
pub fn apply_smudge_filter_with_attributes_cow<'a>(
config: &GitConfig,
attributes: &[AttributeCheck],
path: &[u8],
content: &'a [u8],
) -> Result<Cow<'a, [u8]>> {
apply_smudge_filter_with_attributes_cow_format(
config,
attributes,
path,
content,
ObjectFormat::Sha1,
)
}
fn apply_smudge_filter_with_attributes_cow_format<'a>(
config: &GitConfig,
attributes: &[AttributeCheck],
path: &[u8],
content: &'a [u8],
format: ObjectFormat,
) -> Result<Cow<'a, [u8]>> {
let plan = ContentFilterPlan::resolve(config, attributes);
let mut data = Cow::Borrowed(content);
if plan.ident {
data = ident_to_worktree_cow(format, data)?;
}
if plan.eol == EolConversion::Crlf
&& plan.convert_eol(&data)
&& plan.will_convert_lf_to_crlf(&data)
{
data = Cow::Owned(convert_lf_to_crlf(&data));
}
if let Some(driver) = &plan.driver {
data = run_driver(
driver,
driver.smudge.as_deref(),
"smudge",
Some(format),
path,
data,
)?;
}
Ok(data)
}
fn run_driver<'a>(
driver: &FilterDriver,
command: Option<&str>,
direction: &str,
format: Option<ObjectFormat>,
path: &[u8],
content: Cow<'a, [u8]>,
) -> Result<Cow<'a, [u8]>> {
if let Some(process) = &driver.process {
let blob = if direction == "smudge" {
match format {
Some(format) => {
Some(EncodedObject::new(ObjectType::Blob, content.to_vec()).object_id(format)?)
}
None => None,
}
} else {
None
};
match run_process_filter(process, direction, path, &content, blob) {
Ok(ProcessFilterOutcome::Filtered(output)) => return Ok(Cow::Owned(output)),
Ok(ProcessFilterOutcome::Unsupported) => {}
Ok(ProcessFilterOutcome::Status(status)) => {
if driver.required {
return Err(GitError::Command(format!(
"external filter '{}' returned status {status}",
process
)));
}
return Ok(content);
}
Err(err) => {
if err.protocol {
eprintln!("error: external filter '{}' failed", process);
}
if driver.required {
return Err(GitError::Command(err.message));
}
return Ok(content);
}
}
}
let Some(command) = command else {
if driver.required {
let path = String::from_utf8_lossy(path);
let name = String::from_utf8_lossy(&driver.name);
if direction == "clean" {
eprintln!("fatal: {path}: clean filter '{name}' failed");
} else {
eprintln!("fatal: {path}: smudge filter {name} failed");
}
return Err(GitError::Exit(128));
}
return Ok(content);
};
match run_filter_command(command, path, &content) {
Ok(output) => Ok(Cow::Owned(output)),
Err(err) => {
if driver.required {
Err(err)
} else {
Ok(content)
}
}
}
}
fn filter_attribute_checks(worktree_root: &Path, path: &[u8]) -> Result<Vec<AttributeCheck>> {
let requested = filter_attribute_names();
let mut matcher = AttributeMatcher::default();
let git_dir = worktree_root.join(".git");
matcher.configure_case_sensitivity(&git_dir);
if !matcher.read_configured_attributes(worktree_root, &git_dir) {
matcher.read_default_global_attributes();
}
read_dir_attribute_patterns_for_base(worktree_root, &[], &mut matcher)?;
let mut prefix = Vec::new();
let mut parts = path.split(|byte| *byte == b'/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
break;
}
if !prefix.is_empty() {
prefix.push(b'/');
}
prefix.extend_from_slice(part);
let dir = worktree_root.join(repo_path_to_os_path(&prefix)?);
read_dir_attribute_patterns_for_base(&dir, &prefix, &mut matcher)?;
}
read_attribute_patterns(
worktree_root.join(".git").join("info").join("attributes"),
&mut matcher,
&[],
b".git/info/attributes",
false,
);
Ok(matcher.attributes_for_path(path, &requested, false))
}
fn smudge_attribute_checks_from_index(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
path: &[u8],
) -> Result<Vec<AttributeCheck>> {
let requested = filter_attribute_names();
let mut matcher = AttributeMatcher::default();
matcher.configure_case_sensitivity(git_dir);
if !matcher.read_configured_attributes(worktree_root, git_dir) {
matcher.read_default_global_attributes();
}
let index_attributes = index_gitattributes_by_base(git_dir, format)?;
fold_checkout_attribute_frame(worktree_root, &[], &index_attributes, &mut matcher)?;
let mut prefix = Vec::new();
let mut parts = path.split(|byte| *byte == b'/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
break;
}
if !prefix.is_empty() {
prefix.push(b'/');
}
prefix.extend_from_slice(part);
let dir = worktree_root.join(repo_path_to_os_path(&prefix)?);
fold_checkout_attribute_frame(&dir, &prefix, &index_attributes, &mut matcher)?;
}
read_attribute_patterns(
worktree_root.join(".git").join("info").join("attributes"),
&mut matcher,
&[],
b".git/info/attributes",
false,
);
Ok(matcher.attributes_for_path(path, &requested, false))
}
fn fold_checkout_attribute_frame(
dir: &Path,
base: &[u8],
index_attributes: &BTreeMap<Vec<u8>, Vec<u8>>,
matcher: &mut AttributeMatcher,
) -> Result<()> {
let worktree_file = dir.join(".gitattributes");
let source = attribute_source_for_base(base);
if let Ok(contents) = fs::read(&worktree_file) {
read_attribute_patterns_from_bytes(&contents, matcher, base, &source);
} else if let Some(contents) = index_attributes.get(base) {
read_attribute_patterns_from_bytes(contents, matcher, base, &source);
}
Ok(())
}
fn index_gitattributes_by_base(
git_dir: &Path,
format: ObjectFormat,
) -> Result<BTreeMap<Vec<u8>, Vec<u8>>> {
let mut map = BTreeMap::new();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(map);
}
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let entries = Index::parse(&fs::read(index_path)?, format)?.entries;
for entry in entries {
let is_attributes_file =
entry.path == b".gitattributes" || entry.path.as_bytes().ends_with(b"/.gitattributes");
if index_entry_stage(&entry) != 0
|| tree_entry_object_type(entry.mode) != ObjectType::Blob
|| !is_attributes_file
{
continue;
}
let base = match entry.path.as_bytes().strip_suffix(b".gitattributes") {
Some(b"") => Vec::new(),
Some(parent) => parent.strip_suffix(b"/").unwrap_or(parent).to_vec(),
None => continue,
};
let object = db
.read_object(&entry.oid)
.map_err(|err| expect_missing_object_kind(err, entry.oid, MissingObjectKind::Blob))?;
if object.object_type == ObjectType::Blob {
map.insert(base, object.body.clone());
}
}
Ok(map)
}
fn filter_attribute_names() -> Vec<Vec<u8>> {
vec![
b"text".to_vec(),
b"crlf".to_vec(),
b"ident".to_vec(),
b"eol".to_vec(),
b"filter".to_vec(),
]
}
#[derive(Clone)]
struct ConvertStats {
nul: u32,
lonecr: u32,
lonelf: u32,
crlf: u32,
printable: u32,
nonprintable: u32,
}
fn gather_convert_stats(buf: &[u8]) -> ConvertStats {
let mut stats = ConvertStats {
nul: 0,
lonecr: 0,
lonelf: 0,
crlf: 0,
printable: 0,
nonprintable: 0,
};
let mut i = 0;
while i < buf.len() {
let c = buf[i];
if c == b'\r' {
if buf.get(i + 1) == Some(&b'\n') {
stats.crlf += 1;
i += 1;
} else {
stats.lonecr += 1;
}
i += 1;
continue;
}
if c == b'\n' {
stats.lonelf += 1;
i += 1;
continue;
}
if c == 127 {
stats.nonprintable += 1;
} else if c < 32 {
match c {
0x08 | 0x09 | 0x1b | 0x0c => stats.printable += 1,
0 => {
stats.nul += 1;
stats.nonprintable += 1;
}
_ => stats.nonprintable += 1,
}
} else {
stats.printable += 1;
}
i += 1;
}
if buf.last() == Some(&0x1a) {
stats.nonprintable = stats.nonprintable.saturating_sub(1);
}
stats
}
fn has_crlf_in_index(odb: &FileObjectDatabase, oid: &ObjectId) -> bool {
let Ok(object) = odb.read_object(oid) else {
return false;
};
if object.object_type != ObjectType::Blob {
return false;
}
let data = &object.body;
if !data.contains(&b'\r') {
return false;
}
let stats = gather_convert_stats(data);
!convert_is_binary(&stats) && stats.crlf > 0
}
fn convert_is_binary(stats: &ConvertStats) -> bool {
if stats.lonecr > 0 {
return true;
}
if stats.nul > 0 {
return true;
}
(stats.printable >> 7) < stats.nonprintable
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConvFlags {
Off,
Warn,
Die,
}
impl ConvFlags {
pub fn from_config(config: &GitConfig) -> Self {
match config.get("core", None, "safecrlf") {
Some(value) if value.eq_ignore_ascii_case("warn") => ConvFlags::Warn,
Some(_) => {
if config.get_bool("core", None, "safecrlf") == Some(true) {
ConvFlags::Die
} else {
ConvFlags::Off
}
}
None => ConvFlags::Warn,
}
}
}
fn check_safe_crlf(
old_stats: &ConvertStats,
new_stats: &ConvertStats,
flags: ConvFlags,
path: &[u8],
) -> Result<()> {
if flags == ConvFlags::Off {
return Ok(());
}
let display = String::from_utf8_lossy(path);
if old_stats.crlf > 0 && new_stats.crlf == 0 {
match flags {
ConvFlags::Die => {
eprintln!("fatal: CRLF would be replaced by LF in {display}");
return Err(GitError::Exit(128));
}
ConvFlags::Warn => {
eprintln!(
"warning: in the working copy of '{display}', CRLF will be replaced by LF the next time Git touches it"
);
}
ConvFlags::Off => unreachable!("handled above"),
}
} else if old_stats.lonelf > 0 && new_stats.lonelf == 0 {
match flags {
ConvFlags::Die => {
eprintln!("fatal: LF would be replaced by CRLF in {display}");
return Err(GitError::Exit(128));
}
ConvFlags::Warn => {
eprintln!(
"warning: in the working copy of '{display}', LF will be replaced by CRLF the next time Git touches it"
);
}
ConvFlags::Off => unreachable!("handled above"),
}
}
Ok(())
}
fn convert_stats_ascii(content: &[u8]) -> &'static str {
if content.is_empty() {
return "none";
}
let stats = gather_convert_stats(content);
if convert_is_binary(&stats) {
return "-text";
}
match (stats.lonelf > 0, stats.crlf > 0) {
(true, false) => "lf",
(false, true) => "crlf",
(true, true) => "mixed",
(false, false) => "none",
}
}
fn convert_attr_ascii(checks: &[AttributeCheck]) -> &'static str {
fn state_of<'a>(checks: &'a [AttributeCheck], name: &[u8]) -> Option<&'a AttributeState> {
checks
.iter()
.find(|check| check.attribute == name)
.and_then(|check| check.state.as_ref())
}
#[derive(Clone, Copy, PartialEq)]
enum Action {
Undefined,
Binary,
Text,
TextInput,
TextCrlf,
Auto,
AutoCrlf,
AutoInput,
}
fn check_crlf(state: Option<&AttributeState>) -> Action {
match state {
Some(AttributeState::Set) => Action::Text,
Some(AttributeState::Unset) => Action::Binary,
Some(AttributeState::Value(value)) if value == b"input" => Action::TextInput,
Some(AttributeState::Value(value)) if value == b"auto" => Action::Auto,
_ => Action::Undefined,
}
}
let mut action = check_crlf(state_of(checks, b"text"));
if action == Action::Undefined {
action = check_crlf(state_of(checks, b"crlf"));
}
if action != Action::Binary {
let eol = match state_of(checks, b"eol") {
Some(AttributeState::Value(value)) if value == b"lf" => Some(false),
Some(AttributeState::Value(value)) if value == b"crlf" => Some(true),
_ => None,
};
action = match (action, eol) {
(Action::Auto, Some(false)) => Action::AutoInput,
(Action::Auto, Some(true)) => Action::AutoCrlf,
(_, Some(false)) if action != Action::Auto => Action::TextInput,
(_, Some(true)) if action != Action::Auto => Action::TextCrlf,
_ => action,
};
}
match action {
Action::Undefined => "",
Action::Binary => "-text",
Action::Text => "text",
Action::TextInput => "text eol=lf",
Action::TextCrlf => "text eol=crlf",
Action::Auto => "text=auto",
Action::AutoCrlf => "text=auto eol=crlf",
Action::AutoInput => "text=auto eol=lf",
}
}
pub struct EolInfo {
pub index: &'static str,
pub worktree: &'static str,
pub attr: &'static str,
}
impl EolInfo {
pub fn format_prefix(&self) -> String {
format!(
"i/{:<5} w/{:<5} attr/{:<17}\t",
self.index, self.worktree, self.attr
)
}
}
pub fn eol_info_for_path(
worktree_root: impl AsRef<Path>,
path: &[u8],
index_content: Option<&[u8]>,
attr_checks: &[AttributeCheck],
) -> EolInfo {
let index = index_content.map(convert_stats_ascii).unwrap_or("");
let worktree_root = worktree_root.as_ref();
let worktree = match repo_path_to_os_path(path) {
Ok(rel) => {
let absolute = worktree_root.join(rel);
match fs::symlink_metadata(&absolute) {
Ok(meta) if meta.file_type().is_file() => match fs::read(&absolute) {
Ok(content) => convert_stats_ascii_owned(&content),
Err(_) => "",
},
_ => "",
}
}
Err(_) => "",
};
let attr = convert_attr_ascii(attr_checks);
EolInfo {
index,
worktree,
attr,
}
}
fn convert_stats_ascii_owned(content: &[u8]) -> &'static str {
convert_stats_ascii(content)
}
pub fn eol_attribute_checks(
worktree_root: impl AsRef<Path>,
path: &[u8],
) -> Result<Vec<AttributeCheck>> {
filter_attribute_checks(worktree_root.as_ref(), path)
}
pub fn deleted_index_entries(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<Vec<IndexEntry>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(Vec::new());
}
let index = Index::parse(&fs::read(index_path)?, format)?;
let mut deleted = Vec::new();
for entry in index.entries {
if !worktree_path(worktree_root, entry.path.as_bytes())?.exists()
&& !index_entry_skip_worktree(&entry)
{
deleted.push(entry);
}
}
Ok(deleted)
}
pub fn modified_index_entries(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<Vec<IndexEntry>> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(Vec::new());
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
if index.entries.iter().any(IndexEntry::is_sparse_dir) {
let db = FileObjectDatabase::from_git_dir(git_dir, format);
expand_sparse_index(&mut index, &db, format)?;
}
let stat_cache = IndexStatCache::from_index(&index, &index_path);
let mut modified = Vec::new();
for entry in index.entries {
let worktree_entry = worktree_entry_for_git_path(
worktree_root,
git_dir,
format,
entry.path.as_bytes(),
&entry.oid,
entry.mode,
Some(&stat_cache),
)?;
let Some(worktree_entry) = worktree_entry else {
if !index_entry_skip_worktree(&entry) {
modified.push(entry);
}
continue;
};
if worktree_entry.mode != entry.mode || worktree_entry.oid != entry.oid {
modified.push(entry);
}
}
Ok(modified)
}
pub fn checkout_branch(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
branch: &str,
committer: Vec<u8>,
) -> Result<CheckoutResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let branch_ref = branch_ref_name(branch)?;
let refs = FileRefStore::new(git_dir, format);
let target = match sley_refs::resolve_ref_peeled(&refs, &branch_ref)? {
Some(oid) => oid,
None => {
checkout_switch_head_symbolic(&refs, branch_ref, committer, branch, None, None)?;
return Ok(CheckoutResult {
branch: branch.into(),
oid: ObjectId::null(format),
files: 0,
});
}
};
let current_head = resolve_head_commit_oid(git_dir, format)?;
let files = if current_head == Some(target) {
0
} else {
checkout_commit_to_index_and_worktree(worktree_root, git_dir, format, &target)?
};
checkout_switch_head_symbolic(
&refs,
branch_ref,
committer,
branch,
Some(target),
Some(target),
)?;
Ok(CheckoutResult {
branch: branch.into(),
oid: target,
files,
})
}
pub fn checkout_detached(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
target: &ObjectId,
committer: Vec<u8>,
message: Vec<u8>,
) -> Result<CheckoutResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let files = checkout_commit_to_index_and_worktree(worktree_root, git_dir, format, target)?;
let refs = FileRefStore::new(git_dir, format);
let zero = ObjectId::null(format);
let mut tx = refs.transaction();
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Direct(*target),
reflog: Some(ReflogEntry {
old_oid: zero,
new_oid: *target,
committer,
message,
}),
});
tx.commit()?;
Ok(CheckoutResult {
branch: target.to_string(),
oid: *target,
files,
})
}
pub fn checkout_branch_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
branch: &str,
committer: Vec<u8>,
config: &GitConfig,
) -> Result<CheckoutResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let branch_ref = branch_ref_name(branch)?;
let refs = FileRefStore::new(git_dir, format);
let target = match sley_refs::resolve_ref_peeled(&refs, &branch_ref)? {
Some(oid) => oid,
None => {
checkout_switch_head_symbolic(&refs, branch_ref, committer, branch, None, None)?;
return Ok(CheckoutResult {
branch: branch.into(),
oid: ObjectId::null(format),
files: 0,
});
}
};
let current_head = resolve_head_commit_oid(git_dir, format)?;
let files = if current_head == Some(target) {
0
} else {
checkout_commit_to_index_and_worktree_filtered(
worktree_root,
git_dir,
format,
&target,
Some(config),
Some(vec![
("ref".to_string(), branch_ref.clone()),
("treeish".to_string(), target.to_hex()),
]),
)?
};
checkout_switch_head_symbolic(
&refs,
branch_ref,
committer,
branch,
Some(target),
Some(target),
)?;
Ok(CheckoutResult {
branch: branch.into(),
oid: target,
files,
})
}
pub fn checkout_detached_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
target: &ObjectId,
committer: Vec<u8>,
message: Vec<u8>,
config: &GitConfig,
) -> Result<CheckoutResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let files = checkout_commit_to_index_and_worktree_filtered(
worktree_root,
git_dir,
format,
target,
Some(config),
Some(vec![("treeish".to_string(), target.to_hex())]),
)?;
let refs = FileRefStore::new(git_dir, format);
let zero = ObjectId::null(format);
let mut tx = refs.transaction();
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Direct(*target),
reflog: Some(ReflogEntry {
old_oid: zero,
new_oid: *target,
committer,
message,
}),
});
tx.commit()?;
Ok(CheckoutResult {
branch: target.to_string(),
oid: *target,
files,
})
}
fn checkout_commit_to_index_and_worktree(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
target: &ObjectId,
) -> Result<usize> {
checkout_commit_to_index_and_worktree_filtered(
worktree_root,
git_dir,
format,
target,
None,
None,
)
}
fn checkout_commit_to_index_and_worktree_filtered(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
target: &ObjectId,
smudge_config: Option<&GitConfig>,
process_metadata: Option<Vec<(String, String)>>,
) -> Result<usize> {
if let Some((sparse, mode)) = active_sparse_checkout(git_dir)? {
return checkout_commit_to_index_and_worktree_sparse(
worktree_root,
git_dir,
format,
target,
Some((&sparse, mode)),
smudge_config,
process_metadata,
);
}
let _process_filter_metadata = set_process_filter_metadata(process_metadata);
let mut dirty = false;
if smudge_config.is_some() {
dirty = !modified_index_entries(worktree_root, git_dir, format)?.is_empty();
} else {
stream_short_status(worktree_root, git_dir, format, |entry| {
if !status_row_is_untracked_or_ignored(entry) {
dirty = true;
return Ok(StreamControl::Stop);
}
Ok(StreamControl::Continue)
})?;
}
if dirty {
return Err(GitError::Transaction(
"checkout requires a clean working tree".into(),
));
}
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let commit = read_commit(&db, format, target)?;
let mut target_entries = BTreeMap::new();
collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
refuse_if_current_working_directory_becomes_file(worktree_root, &target_entries)?;
let attributes = smudge_config
.map(|_| build_tree_attribute_matcher(worktree_root, &db, format, &commit.tree))
.transpose()?;
for path in read_index_entries(git_dir, format)?.keys() {
if !target_entries.contains_key(path) {
remove_worktree_file(worktree_root, path)?;
}
}
let mut index_entries = Vec::new();
for (path, entry) in &target_entries {
if sley_index::is_gitlink(entry.mode) {
index_entries.push(materialize_tree_entry(&db, worktree_root, path, entry)?);
continue;
}
let object = read_expected_object(&db, &entry.oid, ObjectType::Blob)?;
let body: Cow<'_, [u8]> = match (smudge_config, &attributes) {
(Some(config), Some(matcher)) => {
let checks = matcher.attributes_for_path(path, &filter_attribute_names(), false);
apply_smudge_filter_with_attributes_cow_format(
config,
&checks,
path,
&object.body,
format,
)?
}
_ => Cow::Borrowed(&object.body),
};
let file_path = worktree_path(worktree_root, path)?;
prepare_blob_parent_dirs(worktree_root, &file_path)?;
remove_existing_worktree_path(&file_path)?;
fs::write(&file_path, &body)?;
set_worktree_file_mode(&file_path, entry.mode)?;
let metadata = fs::metadata(&file_path)?;
let mut index_entry = index_entry_from_metadata(path.clone(), entry.oid, &metadata);
index_entry.mode = entry.mode;
index_entries.push(index_entry);
}
index_entries.sort_by(|left, right| left.path.cmp(&right.path));
let extensions = preserved_index_extensions(git_dir, format)?;
fs::write(
repository_index_path(git_dir),
Index {
version: 2,
entries: index_entries,
extensions,
checksum: None,
}
.write(format)?,
)?;
Ok(target_entries.len())
}
fn build_tree_attribute_matcher(
worktree_root: &Path,
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
) -> Result<AttributeMatcher> {
let mut matcher = AttributeMatcher::default();
let git_dir = worktree_root.join(".git");
matcher.configure_case_sensitivity(&git_dir);
if !matcher.read_configured_attributes(worktree_root, &git_dir) {
matcher.read_default_global_attributes();
}
collect_attribute_patterns_from_tree(db, format, tree_oid, Vec::new(), &mut matcher)?;
read_attribute_patterns(
worktree_root.join(".git").join("info").join("attributes"),
&mut matcher,
&[],
b".git/info/attributes",
false,
);
Ok(matcher)
}
fn materialize_tree_entry_with_optional_smudge(
db: &FileObjectDatabase,
format: ObjectFormat,
worktree_root: &Path,
path: &[u8],
entry: &TrackedEntry,
smudge_config: Option<&GitConfig>,
attributes: Option<&AttributeMatcher>,
) -> Result<IndexEntry> {
if smudge_config.is_none() || sley_index::is_gitlink(entry.mode) {
return materialize_tree_entry(db, worktree_root, path, entry);
}
let config = smudge_config.expect("checked above");
let matcher = attributes.expect("attributes are built when smudge_config is set");
let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
let checks = matcher.attributes_for_path(path, &filter_attribute_names(), false);
let body = apply_smudge_filter_with_attributes_cow_format(
config,
&checks,
path,
&object.body,
format,
)?;
let file_path = worktree_path(worktree_root, path)?;
prepare_blob_parent_dirs(worktree_root, &file_path)?;
remove_existing_worktree_path(&file_path)?;
fs::write(&file_path, &body)?;
set_worktree_file_mode(&file_path, entry.mode)?;
let metadata = fs::metadata(&file_path)?;
let mut index_entry = index_entry_from_metadata(path.to_vec(), entry.oid, &metadata);
index_entry.mode = entry.mode;
Ok(index_entry)
}
fn checkout_commit_to_index_and_worktree_sparse(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
target: &ObjectId,
sparse: Option<(&SparseCheckout, SparseCheckoutMode)>,
smudge_config: Option<&GitConfig>,
process_metadata: Option<Vec<(String, String)>>,
) -> Result<usize> {
let _process_filter_metadata = set_process_filter_metadata(process_metadata);
let previously_skipped = skip_worktree_paths(git_dir, format)?;
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let commit = read_commit(&db, format, target)?;
let mut target_entries = BTreeMap::new();
collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
let mut dirty = false;
stream_short_status(worktree_root, git_dir, format, |entry| {
if previously_skipped.contains(entry.path) {
return Ok(StreamControl::Continue);
}
if entry.index_mode.is_some_and(sley_index::is_gitlink)
|| entry.worktree_mode.is_some_and(sley_index::is_gitlink)
{
return Ok(StreamControl::Continue);
}
if entry.index == b'?' && entry.worktree == b'?' {
let path = entry.path.strip_suffix(b"/").unwrap_or(entry.path);
if target_entries
.get(path)
.is_some_and(|target| sley_index::is_gitlink(target.mode))
{
return Ok(StreamControl::Continue);
}
}
dirty = true;
Ok(StreamControl::Stop)
})?;
if dirty {
return Err(GitError::Transaction(
"checkout requires a clean working tree".into(),
));
}
let matcher = sparse.map(|(spec, mode)| SparseMatcher::new(spec, mode));
let attributes = smudge_config
.map(|_| build_tree_attribute_matcher(worktree_root, &db, format, &commit.tree))
.transpose()?;
for path in read_index_entries(git_dir, format)?.keys() {
if target_entries.contains_key(path) {
continue;
}
if previously_skipped.contains(path) {
continue;
}
remove_worktree_file(worktree_root, path)?;
}
let mut index_entries = Vec::new();
for (path, entry) in &target_entries {
let in_cone = matcher.as_ref().map_or_else(
|| !previously_skipped.contains(path),
|matcher| matcher.includes_file(path),
);
let index_entry = if in_cone {
materialize_tree_entry_with_optional_smudge(
&db,
format,
worktree_root,
path,
entry,
smudge_config,
attributes.as_ref(),
)?
} else {
remove_worktree_file(worktree_root, path)?;
let mut index_entry = restored_head_index_entry(worktree_root, &db, path, entry)?;
set_skip_worktree(&mut index_entry);
index_entry
};
index_entries.push(index_entry);
}
index_entries.sort_by(|left, right| left.path.cmp(&right.path));
let mut index = Index {
version: 2,
entries: index_entries,
extensions: preserved_index_extensions(git_dir, format)?,
checksum: None,
};
normalize_index_version_for_extended_flags(&mut index);
write_repository_index_ref(git_dir, format, &index)?;
Ok(target_entries.len())
}
fn skip_worktree_paths(git_dir: &Path, format: ObjectFormat) -> Result<BTreeSet<Vec<u8>>> {
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(BTreeSet::new());
}
let index = Index::parse(&fs::read(index_path)?, format)?;
Ok(index
.entries
.into_iter()
.filter(index_entry_skip_worktree)
.map(|entry| entry.path.into_bytes())
.collect())
}
pub fn restore_worktree_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<RestoreResult> {
restore_worktree_paths_inner(
worktree_root.as_ref(),
git_dir.as_ref(),
format,
paths,
None,
)
}
pub fn restore_worktree_paths_filtered(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
config: &GitConfig,
) -> Result<RestoreResult> {
restore_worktree_paths_inner(
worktree_root.as_ref(),
git_dir.as_ref(),
format,
paths,
Some(config),
)
}
fn restore_worktree_paths_inner(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
paths: &[PathBuf],
smudge_config: Option<&GitConfig>,
) -> Result<RestoreResult> {
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Err(GitError::Exit(1));
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
let stat_cache = IndexStatCache::from_index(&index, &index_path);
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let mut restored = BTreeSet::new();
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let absolute = normalize_absolute_path_lexically(&absolute);
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
let recursive = path == Path::new(".")
|| path.to_string_lossy().ends_with('/')
|| absolute.is_dir()
|| index_has_entry_under(&index.entries, &git_path);
let mut matched = false;
let matched_positions = index
.entries
.iter()
.enumerate()
.filter_map(|(position, entry)| {
(entry.path.as_bytes() == git_path.as_slice()
|| (recursive && index_entry_is_under_path(entry.path.as_bytes(), &git_path)))
.then_some(position)
})
.collect::<Vec<_>>();
for position in matched_positions {
let refreshed = restore_index_entry(
worktree_root,
git_dir,
format,
&db,
&index.entries[position],
smudge_config,
Some(&stat_cache),
)?;
restored.insert(index.entries[position].path.clone());
matched = true;
if let Some(refreshed) = refreshed {
index.entries[position] = refreshed;
}
}
if !matched {
eprintln!(
"error: pathspec '{}' did not match any file(s) known to git",
path.display()
);
return Err(GitError::Exit(1));
}
}
write_repository_index_ref(git_dir, format, &index)?;
Ok(RestoreResult {
restored: restored.len(),
})
}
pub fn checkout_index_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
options: CheckoutIndexPathOptions<'_>,
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Err(GitError::Exit(1));
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
if options.merge {
checkout_unmerge_resolve_undo_paths(worktree_root, &mut index, format, paths)?;
}
let stat_cache = IndexStatCache::from_index(&index, &index_path);
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let selected = checkout_selected_index_paths(worktree_root, &index, paths)?;
if options.stage.is_none() && !options.merge && !options.force {
for path in &selected {
if checkout_path_is_unmerged(&index, path) {
eprintln!(
"error: path '{}' is unmerged",
String::from_utf8_lossy(path)
);
return Err(GitError::Exit(1));
}
}
}
let mut refreshed = BTreeMap::new();
let mut restored = BTreeSet::new();
for path in selected {
let positions = index
.entries
.iter()
.enumerate()
.filter_map(|(position, entry)| (entry.path.as_bytes() == path).then_some(position))
.collect::<Vec<_>>();
let stage0 = positions
.iter()
.copied()
.find(|position| index.entries[*position].stage() == Stage::Normal);
let is_unmerged = positions
.iter()
.any(|position| index.entries[*position].stage() != Stage::Normal);
if is_unmerged {
if let Some(stage) = options.stage {
let wanted = match stage {
CheckoutStage::Ours => Stage::Ours,
CheckoutStage::Theirs => Stage::Theirs,
};
let Some(position) = positions
.iter()
.copied()
.find(|position| index.entries[*position].stage() == wanted)
else {
eprintln!(
"error: path '{}' does not have {} version",
String::from_utf8_lossy(&path),
match stage {
CheckoutStage::Ours => "our",
CheckoutStage::Theirs => "their",
}
);
return Err(GitError::Exit(1));
};
checkout_write_index_entry_to_worktree(
worktree_root,
git_dir,
format,
&db,
&index.entries[position],
options.smudge_config,
Some(&stat_cache),
)?;
restored.insert(path);
continue;
}
if options.merge {
checkout_merge_unmerged_path(
worktree_root,
&db,
&index,
&positions,
options.conflict_style,
)?;
restored.insert(path);
continue;
}
if options.force {
continue;
}
}
if let Some(position) = stage0 {
if let Some(updated) = checkout_write_index_entry_to_worktree(
worktree_root,
git_dir,
format,
&db,
&index.entries[position],
options.smudge_config,
Some(&stat_cache),
)? {
refreshed.insert(position, updated);
}
restored.insert(path);
}
}
for (position, entry) in refreshed {
index.entries[position] = entry;
}
if !index.entries.is_empty() {
write_repository_index_ref(git_dir, format, &index)?;
}
Ok(RestoreResult {
restored: restored.len(),
})
}
pub fn unresolve_index_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<()> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
if !index_path.exists() {
return Ok(());
}
let mut index = Index::parse(&fs::read(&index_path)?, format)?;
checkout_unmerge_resolve_undo_paths(worktree_root, &mut index, format, paths)?;
write_repository_index_ref(git_dir, format, &index)
}
fn checkout_selected_index_paths(
worktree_root: &Path,
index: &Index,
paths: &[PathBuf],
) -> Result<BTreeSet<Vec<u8>>> {
let index_paths = index
.entries
.iter()
.map(|entry| entry.path.as_bytes().to_vec())
.collect::<BTreeSet<_>>();
let mut selected = BTreeSet::new();
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let absolute = normalize_absolute_path_lexically(&absolute);
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
let recursive = path == Path::new(".")
|| path.to_string_lossy().ends_with('/')
|| absolute.is_dir()
|| index_paths
.iter()
.any(|entry| index_entry_is_under_path(entry, &git_path));
let matched = index_paths
.iter()
.filter(|entry| {
entry.as_slice() == git_path.as_slice()
|| (recursive && index_entry_is_under_path(entry, &git_path))
})
.cloned()
.collect::<Vec<_>>();
if matched.is_empty() {
eprintln!(
"error: pathspec '{}' did not match any file(s) known to git",
path.display()
);
return Err(GitError::Exit(1));
}
selected.extend(matched);
}
Ok(selected)
}
fn checkout_unmerge_resolve_undo_paths(
worktree_root: &Path,
index: &mut Index,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<()> {
let records = parse_resolve_undo_records(index.extension(b"REUC")?, format)?;
if records.is_empty() {
return Ok(());
}
let mut remaining = Vec::new();
let mut unmerged_any = false;
for record in records {
if checkout_pathspecs_match_git_path(worktree_root, paths, &record.path)? {
remove_index_entries_with_path(&mut index.entries, &record.path);
for (idx, stage) in record.stages.into_iter().enumerate() {
let Some((mode, oid)) = stage else {
continue;
};
index.entries.push(resolve_undo_index_entry(
record.path.clone(),
mode,
oid,
(idx + 1) as u16,
));
}
unmerged_any = true;
} else {
remaining.push(record);
}
}
if unmerged_any {
index.entries.sort_by(compare_index_key);
normalize_index_version_for_extended_flags(index);
set_resolve_undo_extension(index, &remaining)?;
}
Ok(())
}
fn checkout_pathspecs_match_git_path(
worktree_root: &Path,
paths: &[PathBuf],
candidate: &[u8],
) -> Result<bool> {
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
let recursive = path == Path::new(".")
|| path.to_string_lossy().ends_with('/')
|| absolute.is_dir()
|| index_entry_is_under_path(candidate, &git_path);
if candidate == git_path.as_slice()
|| (recursive && index_entry_is_under_path(candidate, &git_path))
{
return Ok(true);
}
}
Ok(false)
}
fn resolve_undo_index_entry(path: Vec<u8>, mode: u32, oid: ObjectId, stage: u16) -> IndexEntry {
let name_len = (path
.len()
.min(sley_index::INDEX_FLAG_NAME_LENGTH_MASK as usize)) as u16;
IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
size: 0,
oid,
flags: name_len | (stage << 12),
flags_extended: 0,
path: path.into(),
}
}
fn checkout_path_is_unmerged(index: &Index, path: &[u8]) -> bool {
index
.entries
.iter()
.any(|entry| entry.path.as_bytes() == path && entry.stage() != Stage::Normal)
}
fn checkout_write_index_entry_to_worktree(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
entry: &IndexEntry,
smudge_config: Option<&GitConfig>,
stat_cache: Option<&IndexStatCache>,
) -> Result<Option<IndexEntry>> {
restore_index_entry(
worktree_root,
git_dir,
format,
db,
entry,
smudge_config,
stat_cache,
)
}
fn checkout_merge_unmerged_path(
worktree_root: &Path,
db: &FileObjectDatabase,
index: &Index,
positions: &[usize],
style: CheckoutConflictStyle,
) -> Result<()> {
let mut base = None;
let mut ours = None;
let mut theirs = None;
for position in positions {
let entry = &index.entries[*position];
match entry.stage() {
Stage::Base => base = Some(entry),
Stage::Ours => ours = Some(entry),
Stage::Theirs => theirs = Some(entry),
Stage::Normal => {}
}
}
let Some(ours) = ours else {
return Ok(());
};
let Some(theirs) = theirs else {
return Ok(());
};
let base_body = match base {
Some(entry) => read_expected_object(db, &entry.oid, ObjectType::Blob)?
.body
.clone(),
None => Vec::new(),
};
let ours_body = read_expected_object(db, &ours.oid, ObjectType::Blob)?
.body
.clone();
let theirs_body = read_expected_object(db, &theirs.oid, ObjectType::Blob)?
.body
.clone();
let result = sley_diff_merge::merge_blobs(
&base_body,
&ours_body,
&theirs_body,
&sley_diff_merge::MergeBlobOptions {
ours_label: "ours",
theirs_label: "theirs",
base_label: "base",
style: match style {
CheckoutConflictStyle::Merge => sley_diff_merge::ConflictStyle::Merge,
CheckoutConflictStyle::Diff3 => sley_diff_merge::ConflictStyle::Diff3,
},
},
);
let file_path = worktree_path(worktree_root, ours.path.as_bytes())?;
prepare_blob_parent_dirs(worktree_root, &file_path)?;
remove_existing_worktree_path(&file_path)?;
fs::write(&file_path, result.content)?;
set_worktree_file_mode(&file_path, ours.mode)?;
Ok(())
}
pub fn restore_index_paths_from_head(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let head_entries = head_tree_entries(git_dir, format, &db)?;
restore_index_paths_from_entries(
worktree_root,
git_dir,
format,
&db,
index,
&head_entries,
paths,
false,
)
}
pub fn restore_index_paths_from_tree(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
tree_oid: &ObjectId,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let source_entries = tree_entries(&db, format, tree_oid)?;
restore_index_paths_from_entries(
worktree_root,
git_dir,
format,
&db,
index,
&source_entries,
paths,
false,
)
}
pub fn restore_index_paths_from_tree_allow_unmatched(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
tree_oid: &ObjectId,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let source_entries = tree_entries(&db, format, tree_oid)?;
restore_index_paths_from_entries(
worktree_root,
git_dir,
format,
&db,
index,
&source_entries,
paths,
true,
)
}
fn restore_index_paths_from_entries(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
mut index: Index,
source_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
paths: &[PathBuf],
allow_unmatched: bool,
) -> Result<RestoreResult> {
let sparse = active_sparse_checkout(git_dir)?;
if index.is_sparse() {
expand_sparse_index(&mut index, db, format)?;
}
let index_version = index.version;
let extensions = index_extensions_without_cache_tree(&index.extensions);
let mut index_entries = index
.entries
.into_iter()
.map(|entry| (entry.path.as_bytes().to_vec(), entry))
.collect::<BTreeMap<_, _>>();
let prior_skip_worktree = index_entries
.iter()
.filter(|(_, entry)| entry.is_skip_worktree())
.map(|(path, _)| path.clone())
.collect::<BTreeSet<_>>();
let mut restored = BTreeSet::new();
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
let recursive = path == Path::new(".")
|| path.to_string_lossy().ends_with('/')
|| absolute.is_dir()
|| index_entries
.keys()
.any(|entry| index_entry_is_under_path(entry, &git_path))
|| source_entries
.keys()
.any(|entry| index_entry_is_under_path(entry, &git_path));
let mut matched_paths = BTreeSet::new();
for path in index_entries.keys().chain(source_entries.keys()) {
if path.as_slice() == git_path.as_slice()
|| (recursive && index_entry_is_under_path(path, &git_path))
{
matched_paths.insert(path.clone());
}
}
if matched_paths.is_empty() {
if allow_unmatched {
continue;
}
eprintln!(
"error: pathspec '{}' did not match any file(s) known to git",
path.display()
);
return Err(GitError::Exit(1));
}
for path in matched_paths {
if let Some(entry) = source_entries.get(&path) {
let unchanged = index_entries.get(&path).is_some_and(|existing| {
existing.oid == entry.oid
&& existing.mode == entry.mode
&& !existing.is_intent_to_add()
});
if !unchanged {
let mut restored = restored_head_index_entry(worktree_root, db, &path, entry)?;
if prior_skip_worktree.contains(&path) {
restored.set_skip_worktree(true);
}
index_entries.insert(path.clone(), restored);
}
} else {
index_entries.remove(&path);
}
restored.insert(path);
}
}
let mut entries = index_entries.into_values().collect::<Vec<_>>();
entries.sort_by(|left, right| left.path.cmp(&right.path));
let restored_paths = restored.iter().cloned().collect::<Vec<_>>();
let mut index = Index {
version: index_version,
entries,
extensions,
checksum: None,
};
invalidate_untracked_cache_for_git_paths(&mut index, format, &restored_paths)?;
if let Some((sparse, mode)) = sparse
&& sparse.sparse_index
{
let matcher = SparseMatcher::new(&sparse, mode);
collapse_to_sparse_index(&mut index, &matcher, db, format)?;
}
write_repository_index_ref(git_dir, format, &index)?;
Ok(RestoreResult {
restored: restored.len(),
})
}
pub fn restore_index_and_worktree_paths_from_head(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let head_entries = head_tree_entries(git_dir, format, &db)?;
restore_index_and_worktree_paths_from_entries(
worktree_root,
git_dir,
format,
&db,
index,
&head_entries,
paths,
)
}
pub fn restore_index_and_worktree_paths_from_tree(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
tree_oid: &ObjectId,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let source_entries = tree_entries(&db, format, tree_oid)?;
restore_index_and_worktree_paths_from_entries(
worktree_root,
git_dir,
format,
&db,
index,
&source_entries,
paths,
)
}
fn restore_index_and_worktree_paths_from_entries(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
index: Index,
source_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let index_version = index.version;
let extensions = index_extensions_without_cache_tree(&index.extensions);
let mut index_entries = index
.entries
.into_iter()
.map(|entry| (entry.path.as_bytes().to_vec(), entry))
.collect::<BTreeMap<_, _>>();
let mut restored = BTreeSet::new();
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
let recursive = path == Path::new(".")
|| path.to_string_lossy().ends_with('/')
|| absolute.is_dir()
|| index_entries
.keys()
.any(|entry| index_entry_is_under_path(entry, &git_path))
|| source_entries
.keys()
.any(|entry| index_entry_is_under_path(entry, &git_path));
let mut matched_paths = BTreeSet::new();
for path in index_entries.keys().chain(source_entries.keys()) {
if path.as_slice() == git_path.as_slice()
|| (recursive && index_entry_is_under_path(path, &git_path))
{
matched_paths.insert(path.clone());
}
}
if matched_paths.is_empty() {
eprintln!(
"error: pathspec '{}' did not match any file(s) known to git",
path.display()
);
return Err(GitError::Exit(1));
}
for path in matched_paths {
if let Some(entry) = source_entries.get(&path) {
index_entries.insert(
path.clone(),
restore_head_entry_to_worktree_and_index(worktree_root, db, &path, entry)?,
);
} else {
index_entries.remove(&path);
remove_worktree_file(worktree_root, &path)?;
}
restored.insert(path);
}
}
let mut entries = index_entries.into_values().collect::<Vec<_>>();
entries.sort_by(|left, right| left.path.cmp(&right.path));
let restored_paths = restored.iter().cloned().collect::<Vec<_>>();
let mut index = Index {
version: index_version,
entries,
extensions,
checksum: None,
};
invalidate_untracked_cache_for_git_paths(&mut index, format, &restored_paths)?;
write_repository_index_ref(git_dir, format, &index)?;
Ok(RestoreResult {
restored: restored.len(),
})
}
pub fn reset_index_and_worktree_to_commit(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
commit_oid: &ObjectId,
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let commit = read_commit(&db, format, commit_oid)?;
let mut target_entries = BTreeMap::new();
collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
refuse_if_current_working_directory_becomes_file(worktree_root, &target_entries)?;
let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
let attributes = build_tree_attribute_matcher(worktree_root, &db, format, &commit.tree)?;
for path in current_index_paths(git_dir, format, &db)? {
if !target_entries.contains_key(&path) {
remove_worktree_file(worktree_root, &path)?;
}
}
let mut index_entries = Vec::new();
for (path, entry) in &target_entries {
index_entries.push(materialize_tree_entry_filtered(
&db,
format,
worktree_root,
path,
entry,
&config,
&attributes,
)?);
}
index_entries.sort_by(|left, right| left.path.cmp(&right.path));
let extensions = preserved_index_extensions(git_dir, format)?;
fs::write(
repository_index_path(git_dir),
Index {
version: 2,
entries: index_entries,
extensions,
checksum: None,
}
.write(format)?,
)?;
Ok(RestoreResult {
restored: target_entries.len(),
})
}
fn current_index_paths(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
) -> Result<BTreeSet<Vec<u8>>> {
let (index, _stat_cache, _head_matches) = read_index_with_stat_cache(git_dir, format, db)?;
Ok(index
.entries
.into_iter()
.map(|entry| entry.path.into_bytes())
.collect())
}
fn materialize_tree_entry(
db: &FileObjectDatabase,
worktree_root: &Path,
path: &[u8],
entry: &TrackedEntry,
) -> Result<IndexEntry> {
if sley_index::is_gitlink(entry.mode) {
let dir_path = worktree_path(worktree_root, path)?;
materialize_gitlink_dir(worktree_root, &dir_path)?;
return Ok(IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode: entry.mode,
uid: 0,
gid: 0,
size: 0,
oid: entry.oid,
flags: path.len().min(0x0fff) as u16,
flags_extended: 0,
path: BString::from(path),
});
}
let file_path = write_worktree_blob_entry(db, worktree_root, path, entry)?;
let metadata = fs::symlink_metadata(&file_path)?;
let mut index_entry = index_entry_from_metadata(path.to_vec(), entry.oid, &metadata);
index_entry.mode = entry.mode;
Ok(index_entry)
}
fn materialize_gitlink_dir(worktree_root: &Path, dir_path: &Path) -> Result<()> {
prepare_blob_parent_dirs(worktree_root, dir_path)?;
if fs::symlink_metadata(dir_path).is_ok_and(|metadata| !metadata.is_dir()) {
remove_existing_worktree_path(dir_path)?;
}
fs::create_dir_all(dir_path)?;
Ok(())
}
fn materialize_tree_entry_filtered(
db: &FileObjectDatabase,
format: ObjectFormat,
worktree_root: &Path,
path: &[u8],
entry: &TrackedEntry,
config: &GitConfig,
attributes: &AttributeMatcher,
) -> Result<IndexEntry> {
if sley_index::is_gitlink(entry.mode) || (entry.mode & 0o170000) == 0o120000 {
return materialize_tree_entry(db, worktree_root, path, entry);
}
let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
let checks = attributes.attributes_for_path(path, &filter_attribute_names(), false);
let body = apply_smudge_filter_with_attributes_cow_format(
config,
&checks,
path,
&object.body,
format,
)?;
let file_path = worktree_path(worktree_root, path)?;
prepare_blob_parent_dirs(worktree_root, &file_path)?;
remove_existing_worktree_path(&file_path)?;
fs::write(&file_path, &body)?;
set_worktree_file_mode(&file_path, entry.mode)?;
let metadata = fs::symlink_metadata(&file_path)?;
let mut index_entry = index_entry_from_metadata(path.to_vec(), entry.oid, &metadata);
index_entry.mode = entry.mode;
Ok(index_entry)
}
fn write_worktree_blob_entry(
db: &FileObjectDatabase,
worktree_root: &Path,
path: &[u8],
entry: &TrackedEntry,
) -> Result<PathBuf> {
let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
let file_path = worktree_path(worktree_root, path)?;
prepare_blob_parent_dirs(worktree_root, &file_path)?;
remove_existing_worktree_path(&file_path)?;
if (entry.mode & 0o170000) == 0o120000 {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let target =
std::path::PathBuf::from(std::ffi::OsString::from_vec(object.body.clone()));
std::os::unix::fs::symlink(&target, &file_path)?;
}
#[cfg(not(unix))]
fs::write(&file_path, &object.body)?;
} else {
fs::write(&file_path, &object.body)?;
set_worktree_file_mode(&file_path, entry.mode)?;
}
Ok(file_path)
}
fn prepare_blob_parent_dirs(worktree_root: &Path, file_path: &Path) -> Result<()> {
let parent = match file_path.parent() {
Some(parent) => parent,
None => return Ok(()),
};
if parent.is_dir() {
return Ok(());
}
let mut components: Vec<&Path> = Vec::new();
let mut cursor = Some(parent);
while let Some(dir) = cursor {
if dir == worktree_root {
break;
}
components.push(dir);
cursor = dir.parent();
if cursor.is_none() {
break;
}
}
for dir in components.iter().rev() {
match fs::symlink_metadata(dir) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => {
fs::remove_file(dir)?;
fs::create_dir(dir)?;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(dir)?;
}
Err(err) => return Err(err.into()),
}
}
Ok(())
}
fn remove_existing_worktree_path(file_path: &Path) -> Result<()> {
let metadata = match fs::symlink_metadata(file_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
if metadata.is_dir() {
if path_is_original_cwd(file_path) {
return refuse_remove_current_working_directory(file_path);
}
match fs::remove_dir_all(file_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
} else {
fs::remove_file(file_path)?;
}
Ok(())
}
#[cfg(unix)]
fn set_worktree_file_mode(file_path: &Path, entry_mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = match entry_mode {
0o100755 => 0o755,
0o100644 => 0o644,
_ => return Ok(()),
};
fs::set_permissions(file_path, fs::Permissions::from_mode(perms))?;
Ok(())
}
#[cfg(not(unix))]
fn set_worktree_file_mode(_file_path: &Path, _entry_mode: u32) -> Result<()> {
Ok(())
}
pub fn checkout_tree_to_index_and_worktree(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
tree_oid: &ObjectId,
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let mut target_entries = BTreeMap::new();
collect_tree_entries(&db, format, tree_oid, &mut target_entries)?;
for path in read_index_entries(git_dir, format)?.keys() {
if !target_entries.contains_key(path) {
remove_worktree_file(worktree_root, path)?;
}
}
let mut index_entries = Vec::new();
for (path, entry) in &target_entries {
index_entries.push(materialize_tree_entry(&db, worktree_root, path, entry)?);
}
index_entries.sort_by(|left, right| left.path.cmp(&right.path));
let extensions = preserved_index_extensions(git_dir, format)?;
fs::write(
repository_index_path(git_dir),
Index {
version: 2,
entries: index_entries,
extensions,
checksum: None,
}
.write(format)?,
)?;
Ok(RestoreResult {
restored: target_entries.len(),
})
}
pub fn reset_index_to_commit(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
commit_oid: &ObjectId,
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let commit = read_commit(&db, format, commit_oid)?;
let mut target_entries = BTreeMap::new();
collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
let index_path = repository_index_path(git_dir);
let prior_skip_worktree: BTreeSet<Vec<u8>> = match fs::read(&index_path) {
Ok(bytes) => Index::parse(&bytes, format)?
.entries
.iter()
.filter(|entry| entry.is_skip_worktree())
.map(|entry| entry.path.as_bytes().to_vec())
.collect(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(),
Err(err) => return Err(err.into()),
};
let mut index_entries = Vec::new();
for (path, entry) in &target_entries {
let mut restored = restored_head_index_entry(worktree_root, &db, path, entry)?;
if prior_skip_worktree.contains(path) {
restored.set_skip_worktree(true);
}
index_entries.push(restored);
}
index_entries.sort_by(|left, right| left.path.cmp(&right.path));
let mut index = Index {
version: 2,
entries: index_entries,
extensions: preserved_index_extensions(git_dir, format)?,
checksum: None,
};
index.upgrade_version_for_flags();
write_repository_index_ref(git_dir, format, &index)?;
Ok(RestoreResult {
restored: target_entries.len(),
})
}
pub fn index_from_tree(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
) -> Result<Index> {
let mut entries: Vec<IndexEntry> = Vec::new();
if *tree_oid != ObjectId::empty_tree(format) {
let mut tree_entries = BTreeMap::new();
collect_tree_entries(db, format, tree_oid, &mut tree_entries)?;
entries.reserve(tree_entries.len());
for (path, entry) in tree_entries {
let name_len = (path.len().min(0x0fff)) as u16;
entries.push(IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode: entry.mode,
uid: 0,
gid: 0,
size: 0,
oid: entry.oid,
flags: name_len,
flags_extended: 0,
path: path.into(),
});
}
}
entries.sort_by(|left, right| left.path.cmp(&right.path));
Ok(Index {
version: 2,
entries,
extensions: Vec::new(),
checksum: None,
})
}
pub fn path_in_sparse_checkout(
path: &[u8],
sparse: &SparseCheckout,
mode: SparseCheckoutMode,
) -> bool {
SparseMatcher::new(sparse, mode).includes_file(path)
}
fn active_sparse_checkout(git_dir: &Path) -> Result<Option<(SparseCheckout, SparseCheckoutMode)>> {
let worktree_config = GitConfig::read(git_dir.join("config.worktree")).unwrap_or_default();
let repo_config = GitConfig::read(git_dir.join("config")).unwrap_or_default();
let sparse_enabled = worktree_config
.get_bool("core", None, "sparseCheckout")
.or_else(|| repo_config.get_bool("core", None, "sparseCheckout"))
.unwrap_or(false);
if !sparse_enabled {
return Ok(None);
}
let sparse_file = git_dir.join("info").join("sparse-checkout");
if !sparse_file.exists() {
return Ok(None);
}
let cone = worktree_config
.get_bool("core", None, "sparseCheckoutCone")
.or_else(|| repo_config.get_bool("core", None, "sparseCheckoutCone"))
.unwrap_or(false);
let sparse_index = cone
&& worktree_config
.get_bool("index", None, "sparse")
.or_else(|| repo_config.get_bool("index", None, "sparse"))
.unwrap_or(false);
let bytes = fs::read(sparse_file)?;
let mut patterns = bytes
.split(|byte| *byte == b'\n')
.map(<[u8]>::to_vec)
.collect::<Vec<_>>();
if patterns.last().map(Vec::is_empty) == Some(true) {
patterns.pop();
}
let mode = if cone {
SparseCheckoutMode::Cone
} else {
SparseCheckoutMode::Full
};
Ok(Some((
SparseCheckout {
patterns,
sparse_index,
},
mode,
)))
}
pub fn apply_sparse_checkout(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
sparse: &SparseCheckout,
) -> Result<ApplySparseResult> {
apply_sparse_checkout_with_mode(
worktree_root,
git_dir,
format,
sparse,
SparseCheckoutMode::Auto,
)
}
pub fn apply_sparse_checkout_with_mode(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
sparse: &SparseCheckout,
mode: SparseCheckoutMode,
) -> Result<ApplySparseResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
return Ok(ApplySparseResult {
materialized: Vec::new(),
skipped: Vec::new(),
not_up_to_date: Vec::new(),
});
};
let matcher = SparseMatcher::new(sparse, mode);
let db = FileObjectDatabase::from_git_dir(git_dir, format);
if index.entries.iter().any(IndexEntry::is_sparse_dir) {
expand_sparse_index(&mut index, &db, format)?;
}
let mut materialized = Vec::new();
let mut skipped = Vec::new();
let mut not_up_to_date = Vec::new();
for entry in &mut index.entries {
if index_entry_stage(entry) != 0 {
continue;
}
if matcher.includes_file(entry.path.as_bytes()) {
clear_skip_worktree(entry);
let file_path = worktree_path(worktree_root, entry.path.as_bytes())?;
if !file_path.exists() {
materialize_index_entry_file(&db, worktree_root, &file_path, entry)?;
let metadata = fs::symlink_metadata(&file_path)?;
*entry = index_entry_with_refreshed_stat(entry, &metadata);
}
materialized.push(entry.path.as_bytes().to_vec());
} else {
let file_path = worktree_path(worktree_root, entry.path.as_bytes())?;
match fs::symlink_metadata(&file_path) {
Ok(metadata) if !worktree_entry_is_uptodate(entry, &metadata) => {
clear_skip_worktree(entry);
not_up_to_date.push(entry.path.as_bytes().to_vec());
}
_ => {
set_skip_worktree(entry);
remove_worktree_file(worktree_root, entry.path.as_bytes())?;
skipped.push(entry.path.as_bytes().to_vec());
}
}
}
}
not_up_to_date.sort();
normalize_index_version_for_extended_flags(&mut index);
if sparse.sparse_index {
collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
} else {
index.clear_sparse_extension()?;
}
write_repository_index_ref(git_dir, format, &index)?;
Ok(ApplySparseResult {
materialized,
skipped,
not_up_to_date,
})
}
pub fn expand_sparse_index(
index: &mut Index,
db: &FileObjectDatabase,
format: ObjectFormat,
) -> Result<bool> {
if !index.entries.iter().any(IndexEntry::is_sparse_dir) {
let had_marker = index.is_sparse();
index.clear_sparse_extension()?;
if had_marker {
sley_core::trace2::region("index", "ensure_full_index");
}
return Ok(had_marker);
}
let mut expanded: Vec<IndexEntry> = Vec::with_capacity(index.entries.len());
for entry in std::mem::take(&mut index.entries) {
if !entry.is_sparse_dir() {
expanded.push(entry);
continue;
}
let dir = entry.path.as_bytes();
let dir_prefix = dir; for (rel, (mode, oid)) in sley_diff_merge::flatten_tree(db, format, &entry.oid)? {
let mut full_path = dir_prefix.to_vec();
full_path.extend_from_slice(&rel);
let mut blob = blank_sparse_blob_entry(format, &full_path, mode, oid);
blob.set_skip_worktree(true);
expanded.push(blob);
}
}
expanded.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
index.entries = expanded;
index.clear_sparse_extension()?;
normalize_index_version_for_extended_flags(index);
sley_core::trace2::region("index", "ensure_full_index");
Ok(true)
}
fn index_sparse_dir_contains_path(index: &Index, git_path: &[u8]) -> bool {
index.entries.iter().any(|entry| {
entry.is_sparse_dir()
&& git_path.starts_with(entry.path.as_bytes())
&& git_path.len() > entry.path.len()
})
}
fn blank_sparse_blob_entry(
format: ObjectFormat,
path: &[u8],
mode: u32,
oid: ObjectId,
) -> IndexEntry {
let _ = format;
let mut entry = IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
size: 0,
oid,
flags: 0,
flags_extended: 0,
path: path.into(),
};
entry.refresh_name_length();
entry
}
fn collapse_to_sparse_index(
index: &mut Index,
matcher: &SparseMatcher,
db: &FileObjectDatabase,
format: ObjectFormat,
) -> Result<()> {
if index.entries.iter().any(IndexEntry::is_sparse_dir) {
expand_sparse_index(index, db, format)?;
}
if index.entries.iter().any(|e| index_entry_stage(e) != 0) {
index.clear_sparse_extension()?;
return Ok(());
}
index
.entries
.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
use std::collections::BTreeMap;
let mut dir_has_in_cone: BTreeMap<Vec<u8>, bool> = BTreeMap::new();
for entry in &index.entries {
let path = entry.path.as_bytes();
let in_cone = matcher.includes_file(path);
let mut start = 0usize;
while let Some(rel) = path
.get(start..)
.and_then(|s| s.iter().position(|b| *b == b'/'))
{
let end = start + rel;
let dir = path[..end].to_vec();
let flag = dir_has_in_cone.entry(dir).or_insert(false);
*flag = *flag || in_cone;
start = end + 1;
}
}
let collapsible: Vec<Vec<u8>> = {
let all: Vec<Vec<u8>> = dir_has_in_cone
.iter()
.filter(|(_, has)| !**has)
.map(|(dir, _)| dir.clone())
.collect();
all.iter()
.filter(|dir| {
!all.iter().any(|other| {
other != *dir
&& dir
.strip_prefix(other.as_slice())
.is_some_and(|rest| rest.first() == Some(&b'/'))
})
})
.cloned()
.collect()
};
if collapsible.is_empty() {
index.clear_sparse_extension()?;
return Ok(());
}
let mut checker = db.presence_checker();
let mut new_entries: Vec<IndexEntry> = Vec::with_capacity(index.entries.len());
let mut consumed: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
for dir in &collapsible {
let mut subtree: Vec<&IndexEntry> = index
.entries
.iter()
.filter(|e| {
e.path
.as_bytes()
.strip_prefix(dir.as_slice())
.is_some_and(|rest| rest.first() == Some(&b'/'))
})
.collect();
if subtree.is_empty() {
continue;
}
subtree.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
let mut prefix = dir.clone();
prefix.push(b'/');
let tree_entries: Vec<WriteTreeEntry<'_>> = subtree
.iter()
.map(|e| WriteTreeEntry {
path: e.path.as_bytes(),
mode: e.mode,
oid: e.oid.clone(),
})
.collect();
let tree_oid =
write_tree_entries_stream(&tree_entries, &prefix, None, db, &mut checker, false)?;
for e in &subtree {
consumed.insert(e.path.as_bytes().to_vec());
}
let mut sparse_path = dir.clone();
sparse_path.push(b'/');
let mut sparse_entry =
blank_sparse_blob_entry(format, &sparse_path, SPARSE_DIR_MODE, tree_oid);
sparse_entry.set_skip_worktree(true);
new_entries.push(sparse_entry);
}
for entry in &index.entries {
if consumed.contains(entry.path.as_bytes()) {
continue;
}
new_entries.push(entry.clone());
}
new_entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
index.entries = new_entries;
index.set_sparse_extension();
normalize_index_version_for_extended_flags(index);
sley_core::trace2::region("index", "convert_to_sparse");
Ok(())
}
fn worktree_entry_is_uptodate(entry: &IndexEntry, metadata: &fs::Metadata) -> bool {
if u64::from(entry.size) != metadata.len() {
return false;
}
let Some((mtime_seconds, mtime_nanoseconds)) = file_mtime_parts(metadata) else {
return false;
};
u64::from(entry.mtime_seconds) == mtime_seconds
&& u64::from(entry.mtime_nanoseconds) == mtime_nanoseconds
}
fn worktree_entry_ref_is_uptodate(entry: &IndexEntryRef<'_>, metadata: &fs::Metadata) -> bool {
if u64::from(entry.size) != metadata.len() {
return false;
}
let Some((mtime_seconds, mtime_nanoseconds)) = file_mtime_parts(metadata) else {
return false;
};
u64::from(entry.mtime_seconds) == mtime_seconds
&& u64::from(entry.mtime_nanoseconds) == mtime_nanoseconds
}
fn file_mtime_parts(metadata: &fs::Metadata) -> Option<(u64, u64)> {
let modified = metadata.modified().ok()?;
let duration = modified.duration_since(UNIX_EPOCH).ok()?;
Some((duration.as_secs(), u64::from(duration.subsec_nanos())))
}
pub fn write_metadata_file_atomic(
path: impl AsRef<Path>,
bytes: &[u8],
options: AtomicMetadataWriteOptions,
) -> Result<AtomicMetadataWriteResult> {
let path = path.as_ref();
let parent = path.parent().ok_or_else(|| {
GitError::InvalidPath(format!("metadata path has no parent: {}", path.display()))
})?;
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
let lock_path = metadata_lock_path(path)?;
let mut lock = match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(lock) => lock,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(GitError::Transaction(format!(
"metadata lock already exists: {}",
lock_path.display()
)));
}
Err(err) => return Err(err.into()),
};
if let Err(err) = lock.write_all(bytes) {
let _ = fs::remove_file(&lock_path);
return Err(err.into());
}
if options.fsync_file
&& let Err(err) = lock.sync_all()
{
let _ = fs::remove_file(&lock_path);
return Err(err.into());
}
drop(lock);
if let Err(err) = fs::rename(&lock_path, path) {
let _ = fs::remove_file(&lock_path);
return Err(err.into());
}
if options.fsync_dir
&& let Ok(dir) = fs::File::open(parent)
{
dir.sync_all()?;
}
let metadata = fs::metadata(path)?;
Ok(AtomicMetadataWriteResult {
path: path.to_path_buf(),
len: metadata.len(),
mtime: file_mtime_parts(&metadata),
})
}
fn metadata_lock_path(path: &Path) -> Result<PathBuf> {
let file_name = path.file_name().ok_or_else(|| {
GitError::InvalidPath(format!("metadata path has no filename: {}", path.display()))
})?;
let mut lock_name = file_name.to_os_string();
lock_name.push(".lock");
Ok(path.with_file_name(lock_name))
}
pub fn checkout_detached_sparse(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
target: &ObjectId,
committer: Vec<u8>,
message: Vec<u8>,
sparse: &SparseCheckout,
) -> Result<CheckoutResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let files = checkout_commit_to_index_and_worktree_sparse(
worktree_root,
git_dir,
format,
target,
Some((sparse, SparseCheckoutMode::Auto)),
None,
None,
)?;
let refs = FileRefStore::new(git_dir, format);
let zero = ObjectId::null(format);
let mut tx = refs.transaction();
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Direct(*target),
reflog: Some(ReflogEntry {
old_oid: zero,
new_oid: *target,
committer,
message,
}),
});
tx.commit()?;
Ok(CheckoutResult {
branch: target.to_string(),
oid: *target,
files,
})
}
fn materialize_index_entry_file(
db: &FileObjectDatabase,
worktree_root: &Path,
file_path: &Path,
entry: &IndexEntry,
) -> Result<()> {
if sley_index::is_gitlink(entry.mode) {
materialize_gitlink_dir(worktree_root, file_path)?;
return Ok(());
}
let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
prepare_blob_parent_dirs(worktree_root, file_path)?;
remove_existing_worktree_path(file_path)?;
fs::write(file_path, &object.body)?;
set_worktree_file_mode(file_path, entry.mode)?;
Ok(())
}
fn set_skip_worktree(entry: &mut IndexEntry) {
entry.flags |= INDEX_FLAG_EXTENDED;
entry.flags_extended |= INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
}
fn clear_skip_worktree(entry: &mut IndexEntry) {
entry.flags_extended &= !INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
if entry.flags_extended == 0 {
entry.flags &= !INDEX_FLAG_EXTENDED;
}
}
pub fn restore_worktree_paths_from_head(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let head_entries = head_tree_entries(git_dir, format, &db)?;
restore_worktree_paths_from_entries(worktree_root, &db, index, &head_entries, paths)
}
pub fn restore_worktree_paths_from_tree(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
tree_oid: &ObjectId,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let source_entries = tree_entries(&db, format, tree_oid)?;
restore_worktree_paths_from_entries(worktree_root, &db, index, &source_entries, paths)
}
fn restore_worktree_paths_from_entries(
worktree_root: &Path,
db: &FileObjectDatabase,
index: Index,
source_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
paths: &[PathBuf],
) -> Result<RestoreResult> {
let index_entries = index
.entries
.into_iter()
.map(|entry| entry.path.into_bytes())
.collect::<BTreeSet<_>>();
let mut restored = BTreeSet::new();
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
let recursive = path == Path::new(".")
|| path.to_string_lossy().ends_with('/')
|| absolute.is_dir()
|| index_entries
.iter()
.any(|entry| index_entry_is_under_path(entry, &git_path))
|| source_entries
.keys()
.any(|entry| index_entry_is_under_path(entry, &git_path));
let mut matched_paths = BTreeSet::new();
for path in index_entries.iter().chain(source_entries.keys()) {
if path.as_slice() == git_path.as_slice()
|| (recursive && index_entry_is_under_path(path, &git_path))
{
matched_paths.insert(path.clone());
}
}
if matched_paths.is_empty() {
eprintln!(
"error: pathspec '{}' did not match any file(s) known to git",
path.display()
);
return Err(GitError::Exit(1));
}
for path in matched_paths {
if let Some(entry) = source_entries.get(&path) {
restore_head_entry_to_worktree(worktree_root, db, &path, entry)?;
} else {
remove_worktree_file(worktree_root, &path)?;
}
restored.insert(path);
}
}
Ok(RestoreResult {
restored: restored.len(),
})
}
pub fn remove_index_and_worktree_paths(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
paths: &[PathBuf],
options: RemoveOptions,
config_parameters_env: Option<&str>,
) -> Result<RemoveResult> {
let cwd = env::current_dir()?;
let worktree_root = absolute_path_lexically(worktree_root.as_ref(), &cwd);
let git_dir = absolute_path_lexically(git_dir.as_ref(), &cwd);
let worktree_root = worktree_root.as_path();
let git_dir = git_dir.as_path();
let index_path = repository_index_path(git_dir);
let index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let head_entries = head_tree_entries(git_dir, format, &db)?;
let rm_stat_cache = sley_index::IndexStatCache::from_index(&index, &index_path);
let Index {
version: index_version,
entries: mut index_entry_list,
extensions: index_extensions,
..
} = index;
let index_paths: BTreeSet<Vec<u8>> = index_entry_list
.iter()
.map(|entry| entry.path.as_bytes().to_vec())
.collect();
let sparse_dir_paths: BTreeSet<Vec<u8>> = index_entry_list
.iter()
.filter(|entry| entry.is_sparse_dir())
.map(|entry| entry.path.as_bytes().to_vec())
.collect();
let stage0_gitlink_paths: BTreeSet<Vec<u8>> = index_entry_list
.iter()
.filter(|entry| entry.stage() == Stage::Normal && sley_index::is_gitlink(entry.mode))
.map(|entry| entry.path.as_bytes().to_vec())
.collect();
let gitlink_paths: BTreeSet<Vec<u8>> = index_entry_list
.iter()
.filter(|entry| sley_index::is_gitlink(entry.mode))
.map(|entry| entry.path.as_bytes().to_vec())
.collect();
let gitlink_oids_by_path: BTreeMap<Vec<u8>, BTreeSet<ObjectId>> = {
let mut by_path: BTreeMap<Vec<u8>, BTreeSet<ObjectId>> = BTreeMap::new();
for entry in index_entry_list
.iter()
.filter(|entry| sley_index::is_gitlink(entry.mode))
{
by_path
.entry(entry.path.as_bytes().to_vec())
.or_default()
.insert(entry.oid);
}
by_path
};
let mut selected = BTreeSet::new();
for path in paths {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
let has_trailing_slash = path_has_trailing_separator(&absolute);
let absolute = normalize_absolute_path_lexically(&absolute);
let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
})?;
let git_path = git_path_bytes(relative)?;
if !has_trailing_slash && index_paths.contains(&git_path) {
selected.insert(git_path);
continue;
}
if has_trailing_slash && gitlink_paths.contains(&git_path) && absolute.is_dir() {
selected.insert(git_path);
continue;
}
if pathspec_is_glob(&git_path) {
let glob_matched = index_paths
.iter()
.filter(|entry| {
pathspec_item_matches(&git_path, entry, PathspecMatchMagic::default())
})
.cloned()
.collect::<Vec<_>>();
if !glob_matched.is_empty() {
selected.extend(glob_matched);
continue;
}
if options.ignore_unmatch {
continue;
}
eprintln!(
"fatal: pathspec '{}' did not match any files",
String::from_utf8_lossy(&git_path)
);
return Err(GitError::Exit(128));
}
let matched = index_paths
.iter()
.filter(|entry| {
!sparse_dir_paths.contains(*entry) && index_entry_is_under_path(entry, &git_path)
})
.cloned()
.collect::<Vec<_>>();
if matched.is_empty() {
if options.ignore_unmatch {
continue;
}
eprintln!(
"fatal: pathspec '{}' did not match any files",
String::from_utf8_lossy(&git_path)
);
return Err(GitError::Exit(128));
}
if !options.recursive {
eprintln!(
"fatal: not removing '{}' recursively without -r",
String::from_utf8_lossy(&git_path)
);
return Err(GitError::Exit(128));
}
selected.extend(matched);
}
if !options.force {
let config =
sley_config::read_repo_config(git_dir, config_parameters_env).unwrap_or_default();
let show_hints = config.get_bool("advice", None, "rmhints").unwrap_or(true);
let stage0: BTreeMap<&[u8], &IndexEntry> = index_entry_list
.iter()
.filter(|entry| entry.stage() == Stage::Normal)
.map(|entry| (entry.path.as_bytes(), entry))
.collect();
let mut files_staged: Vec<&[u8]> = Vec::new();
let mut files_cached: Vec<&[u8]> = Vec::new();
let mut files_local: Vec<&[u8]> = Vec::new();
for path in &selected {
let Some(index_entry) = stage0.get(path.as_slice()) else {
if !gitlink_paths.contains(path) {
continue;
}
if rm_submodule_has_local_changes(
worktree_root,
format,
path,
gitlink_oids_by_path.get(path),
) {
files_local.push(path);
}
continue;
};
let worktree_file = worktree_path(worktree_root, path)?;
let local_changes = if sley_index::is_gitlink(index_entry.mode) {
rm_submodule_has_local_changes(
worktree_root,
format,
path,
gitlink_oids_by_path.get(path),
)
} else {
match fs::symlink_metadata(&worktree_file) {
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) || err.raw_os_error() == Some(20) =>
{
continue;
}
Err(err) => return Err(err.into()),
Ok(meta) if meta.is_dir() => continue,
Ok(meta) => {
match rm_stat_cache.index_entry_worktree_stat_verdict(index_entry, &meta) {
sley_index::StatVerdict::Clean => false,
sley_index::StatVerdict::Dirty
| sley_index::StatVerdict::RacyNeedsContentCheck => {
let worktree_bytes = apply_clean_filter(
worktree_root,
git_dir,
&config,
path,
&fs::read(&worktree_file)?,
)?;
let worktree_oid =
EncodedObject::new(ObjectType::Blob, worktree_bytes)
.object_id(format)?;
worktree_oid != index_entry.oid
}
}
}
}
};
let staged_changes = match head_entries.get(path) {
Some(head_entry) => {
head_entry.oid != index_entry.oid || head_entry.mode != index_entry.mode
}
None => true,
};
if local_changes && staged_changes {
if !options.cached || !index_entry.is_intent_to_add() {
files_staged.push(path);
}
} else if !options.cached {
if staged_changes {
files_cached.push(path);
}
if local_changes {
files_local.push(path);
}
}
}
let mut errs = false;
print_rm_error_files(
&files_staged,
"the following file has staged content different from both the\nfile and the HEAD:",
"the following files have staged content different from both the\nfile and the HEAD:",
"\n(use -f to force removal)",
show_hints,
&mut errs,
);
print_rm_error_files(
&files_cached,
"the following file has changes staged in the index:",
"the following files have changes staged in the index:",
"\n(use --cached to keep the file, or -f to force removal)",
show_hints,
&mut errs,
);
print_rm_error_files(
&files_local,
"the following file has local modifications:",
"the following files have local modifications:",
"\n(use --cached to keep the file, or -f to force removal)",
show_hints,
&mut errs,
);
if errs {
return Err(GitError::Exit(1));
}
}
if options.dry_run {
return Ok(RemoveResult {
removed: selected.into_iter().collect(),
});
}
let selected_gitlinks = selected
.iter()
.filter(|path| gitlink_paths.contains(*path))
.cloned()
.collect::<Vec<_>>();
if !options.cached
&& !selected_gitlinks.is_empty()
&& !selected.contains(b".gitmodules".as_slice())
{
ensure_gitmodules_clean_for_submodule_rm(
worktree_root,
git_dir,
format,
&index_entry_list,
&selected_gitlinks,
&config_parameters_env,
)?;
}
if !options.cached {
let mut removed_any = false;
for path in &selected {
let is_gitlink = gitlink_paths.contains(path);
let is_stage0_gitlink = stage0_gitlink_paths.contains(path);
match remove_tracked_worktree_path(
worktree_root,
path,
is_gitlink,
is_stage0_gitlink,
options.force,
)?
{
true => removed_any = true,
false if !removed_any => {
eprintln!(
"fatal: git rm: '{}': Is a directory",
String::from_utf8_lossy(path)
);
return Err(GitError::Exit(128));
}
false => {}
}
}
}
if !options.cached
&& !selected_gitlinks.is_empty()
&& !selected.contains(b".gitmodules".as_slice())
{
remove_submodule_sections_from_gitmodules(
worktree_root,
git_dir,
format,
&mut index_entry_list,
&selected_gitlinks,
&config_parameters_env,
)?;
}
let mut resolve_undo_index = Index {
version: index_version,
entries: index_entry_list.clone(),
extensions: index_extensions,
checksum: None,
};
for path in &selected {
let range = index_entries_path_range(&resolve_undo_index.entries, path);
record_resolve_undo_for_range(&mut resolve_undo_index, format, path, range)?;
}
let entries = index_entry_list
.into_iter()
.filter(|entry| !selected.contains(entry.path.as_bytes()))
.collect::<Vec<_>>();
let extensions = index_extensions_without_cache_tree(&resolve_undo_index.extensions);
let selected_paths = selected.iter().cloned().collect::<Vec<_>>();
let mut index = Index {
version: index_version,
entries,
extensions,
checksum: None,
};
invalidate_untracked_cache_for_git_paths(&mut index, format, &selected_paths)?;
fs::write(index_path, index.write(format)?)?;
Ok(RemoveResult {
removed: selected.into_iter().collect(),
})
}
fn remove_tracked_worktree_path(
root: &Path,
path: &[u8],
is_gitlink: bool,
is_stage0_gitlink: bool,
force: bool,
) -> Result<bool> {
let file = worktree_path(root, path)?;
match fs::symlink_metadata(&file) {
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(true);
}
Err(err) if err.raw_os_error() == Some(20) => return Ok(true), Err(err) => return Err(err.into()),
Ok(meta) if meta.is_dir() => {
if is_gitlink {
if file.join(".git").is_dir() && !is_stage0_gitlink {
return Ok(false);
}
if !force && original_cwd_is_inside(&file) {
let nested_git = file.join(".git");
if nested_git.is_dir() {
let _ = fs::remove_dir_all(nested_git);
}
return Ok(false);
}
if contains_nested_git_dir(&file) {
eprintln!(
"Migrating git directory of '{}' from",
String::from_utf8_lossy(path)
);
}
fs::remove_dir_all(&file)?;
if fs::symlink_metadata(&file).is_ok() {
fs::remove_dir(&file)?;
}
prune_empty_parents(root, file.parent())?;
return Ok(true);
}
return Ok(false);
}
Ok(_) => {}
}
fs::remove_file(&file)?;
prune_empty_parents(root, file.parent())?;
Ok(true)
}
fn rm_submodule_has_local_changes(
worktree_root: &Path,
format: ObjectFormat,
path: &[u8],
expected_oids: Option<&BTreeSet<ObjectId>>,
) -> bool {
let Ok(submodule_root) = worktree_path(worktree_root, path) else {
return false;
};
if !submodule_root.is_dir() {
return false;
}
let head_changed = sley_diff_merge::gitlink_head_oid(&submodule_root, format)
.zip(expected_oids)
.is_some_and(|(head, expected)| !expected.contains(&head));
head_changed || submodule_dirt(&submodule_root) != 0
}
fn remove_submodule_sections_from_gitmodules(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entries: &mut Vec<IndexEntry>,
selected_gitlinks: &[Vec<u8>],
config_parameters_env: &Option<&str>,
) -> Result<()> {
let gitmodules_path = worktree_root.join(".gitmodules");
let Ok(original) = fs::read(&gitmodules_path) else {
return Ok(());
};
let gitmodules_index = index_entries.iter().position(|entry| {
entry.stage() == Stage::Normal && entry.path.as_bytes() == b".gitmodules"
});
if gitmodules_index.is_none() {
return Ok(());
}
let config = GitConfig::parse(&original)?;
let selected = selected_gitlinks
.iter()
.map(|path| String::from_utf8_lossy(path).into_owned())
.collect::<BTreeSet<_>>();
let mut sections = Vec::new();
for section in &config.sections {
if !section.name.eq_ignore_ascii_case("submodule") {
continue;
}
let Some(name) = section.subsection.as_deref() else {
continue;
};
let path = section
.entries
.iter()
.rev()
.find(|entry| entry.key.eq_ignore_ascii_case("path"))
.and_then(|entry| entry.value.as_deref());
if path.is_some_and(|path| selected.contains(path)) {
sections.push(name.to_string());
}
}
let selected_with_sections = sections
.iter()
.filter_map(|name| {
config
.get("submodule", Some(name), "path")
.map(ToOwned::to_owned)
})
.collect::<BTreeSet<_>>();
for path in &selected {
if !selected_with_sections.contains(path) {
eprintln!("warning: Could not find section in .gitmodules where path={path}");
}
}
if sections.is_empty() {
return Ok(());
}
if gitmodules_worktree_differs_from_index(
worktree_root,
git_dir,
format,
index_entries,
&original,
config_parameters_env,
)? {
eprintln!("error: the following file has local modifications:");
eprintln!(" .gitmodules");
eprintln!("(use --cached to keep the file, or -f to force removal)");
return Err(GitError::Exit(1));
}
let mut edited = original;
for name in sections {
let section_name = format!("submodule.{name}");
match sley_config::raw_edit::rename_or_remove_section(&edited, §ion_name, None) {
sley_config::raw_edit::SectionEditOutcome::Changed(out) => edited = out,
sley_config::raw_edit::SectionEditOutcome::NotFound => {
eprintln!("warning: Could not find section in .gitmodules where path={name}");
}
sley_config::raw_edit::SectionEditOutcome::LineTooLong(line) => {
return Err(GitError::InvalidFormat(format!(
"bad config line {line} in .gitmodules"
)));
}
}
}
fs::write(&gitmodules_path, &edited)?;
stage_gitmodules_after_rm(
worktree_root,
git_dir,
format,
index_entries,
config_parameters_env,
)
}
fn ensure_gitmodules_clean_for_submodule_rm(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entries: &[IndexEntry],
selected_gitlinks: &[Vec<u8>],
config_parameters_env: &Option<&str>,
) -> Result<()> {
let gitmodules_path = worktree_root.join(".gitmodules");
let Ok(original) = fs::read(&gitmodules_path) else {
return Ok(());
};
if !index_entries
.iter()
.any(|entry| entry.stage() == Stage::Normal && entry.path.as_bytes() == b".gitmodules")
{
return Ok(());
}
let config = GitConfig::parse(&original)?;
let selected = selected_gitlinks
.iter()
.map(|path| String::from_utf8_lossy(path).into_owned())
.collect::<BTreeSet<_>>();
let has_matching_section = config.sections.iter().any(|section| {
section.name.eq_ignore_ascii_case("submodule")
&& section
.entries
.iter()
.rev()
.find(|entry| entry.key.eq_ignore_ascii_case("path"))
.and_then(|entry| entry.value.as_deref())
.is_some_and(|path| selected.contains(path))
});
if !has_matching_section {
return Ok(());
}
if gitmodules_worktree_differs_from_index(
worktree_root,
git_dir,
format,
index_entries,
&original,
config_parameters_env,
)? {
eprintln!("error: the following file has local modifications:");
eprintln!(" .gitmodules");
eprintln!("(use --cached to keep the file, or -f to force removal)");
return Err(GitError::Exit(1));
}
Ok(())
}
fn gitmodules_worktree_differs_from_index(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entries: &[IndexEntry],
worktree_bytes: &[u8],
config_parameters_env: &Option<&str>,
) -> Result<bool> {
let Some(entry) = index_entries
.iter()
.find(|entry| entry.stage() == Stage::Normal && entry.path.as_bytes() == b".gitmodules")
else {
return Ok(false);
};
let config = sley_config::read_repo_config(git_dir, *config_parameters_env).unwrap_or_default();
let clean = apply_clean_filter(
worktree_root,
git_dir,
&config,
b".gitmodules",
worktree_bytes,
)?;
let oid = EncodedObject::new(ObjectType::Blob, clean).object_id(format)?;
Ok(oid != entry.oid)
}
fn stage_gitmodules_after_rm(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entries: &mut [IndexEntry],
config_parameters_env: &Option<&str>,
) -> Result<()> {
let path = worktree_root.join(".gitmodules");
let bytes = fs::read(&path)?;
let config = sley_config::read_repo_config(git_dir, *config_parameters_env).unwrap_or_default();
let clean = apply_clean_filter(worktree_root, git_dir, &config, b".gitmodules", &bytes)?;
let object = EncodedObject::new(ObjectType::Blob, clean);
let oid = object.object_id(format)?;
let odb = FileObjectDatabase::from_git_dir(git_dir, format);
odb.write_object(object)?;
let metadata = fs::symlink_metadata(&path)?;
let mut entry =
index_entry_from_metadata(BString::from(b".gitmodules".as_slice()), oid, &metadata);
entry.mode = 0o100644;
if let Some(slot) = index_entries
.iter_mut()
.find(|entry| entry.stage() == Stage::Normal && entry.path.as_bytes() == b".gitmodules")
{
*slot = entry;
}
Ok(())
}
fn prepare_gitmodules_for_moved_gitlinks(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entries: &[IndexEntry],
moves: &[GitmodulesMove],
) -> Result<Option<Vec<u8>>> {
if moves.is_empty() {
return Ok(None);
}
let gitmodules_path = worktree_root.join(".gitmodules");
let Ok(original) = fs::read(&gitmodules_path) else {
return Ok(None);
};
if !index_entries
.iter()
.any(|entry| entry.stage() == Stage::Normal && entry.path.as_bytes() == b".gitmodules")
{
return Ok(None);
}
let config = GitConfig::parse(&original)?;
let mut edits = Vec::new();
for gitlink_move in moves {
let source = String::from_utf8_lossy(&gitlink_move.source).into_owned();
let destination = String::from_utf8_lossy(&gitlink_move.destination).into_owned();
let mut matched = false;
for section in &config.sections {
if !section.name.eq_ignore_ascii_case("submodule") {
continue;
}
let Some(name) = section.subsection.as_deref() else {
continue;
};
let path = section
.entries
.iter()
.rev()
.find(|entry| entry.key.eq_ignore_ascii_case("path"))
.and_then(|entry| entry.value.as_deref());
if path == Some(source.as_str()) {
matched = true;
edits.push((name.to_string(), destination.clone()));
}
}
if !matched {
eprintln!("warning: Could not find section in .gitmodules where path={source}");
}
}
if edits.is_empty() {
return Ok(None);
}
if gitmodules_worktree_differs_from_index(
worktree_root,
git_dir,
format,
index_entries,
&original,
&None,
)? {
eprintln!("fatal: Please stage your changes to .gitmodules or stash them to proceed");
return Err(GitError::Exit(128));
}
let mut edited = original;
for (name, destination) in edits {
let mut editor =
sley_config::raw_edit::RawConfigEditor::new(edited, "submodule", Some(&name), "path");
match editor.set_multivar(Some(&destination), None, None, false) {
sley_config::raw_edit::RawEditOutcome::Changed => {}
sley_config::raw_edit::RawEditOutcome::NothingSet => {
eprintln!("warning: Could not find section in .gitmodules where path={name}");
}
}
edited = editor.into_bytes();
}
Ok(Some(edited))
}
fn apply_prepared_gitmodules_move(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entries: &mut [IndexEntry],
edited: Vec<u8>,
) -> Result<()> {
fs::write(worktree_root.join(".gitmodules"), edited)?;
stage_gitmodules_after_rm(worktree_root, git_dir, format, index_entries, &None)
}
fn prepare_moved_gitlink_gitdirs(
worktree_root: &Path,
moves: &[GitmodulesMove],
) -> Result<Vec<GitlinkGitdirMove>> {
let mut gitdir_moves = Vec::new();
for gitlink_move in moves {
let source_root = worktree_path(worktree_root, &gitlink_move.source)?;
if !source_root.join(".git").is_file() {
continue;
}
let Some(git_dir) = sley_diff_merge::gitlink_git_dir(&source_root) else {
continue;
};
gitdir_moves.push(GitlinkGitdirMove {
git_dir: normalize_absolute_path_lexically(&git_dir),
destination_root: worktree_path(worktree_root, &gitlink_move.destination)?,
});
}
Ok(gitdir_moves)
}
fn apply_moved_gitlink_gitdirs(moves: &[GitlinkGitdirMove]) -> Result<()> {
for gitdir_move in moves {
let gitdir_relative =
relative_path_between(&gitdir_move.destination_root, &gitdir_move.git_dir);
let gitdir_value = gitfile_path_value(&gitdir_relative);
fs::write(
gitdir_move.destination_root.join(".git"),
format!("gitdir: {gitdir_value}\n"),
)?;
let config_path = gitdir_move.git_dir.join("config");
let config_bytes = match fs::read(&config_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(err) => return Err(err.into()),
};
let worktree_relative =
relative_path_between(&gitdir_move.git_dir, &gitdir_move.destination_root);
let worktree_value = gitfile_path_value(&worktree_relative);
let mut editor =
sley_config::raw_edit::RawConfigEditor::new(config_bytes, "core", None, "worktree");
match editor.set_multivar(Some(&worktree_value), None, None, false) {
sley_config::raw_edit::RawEditOutcome::Changed => {
sley_config::raw_edit::write_config_file_locked(
&config_path,
&editor.into_bytes(),
sley_config::raw_edit::ConfigFileWriteOptions::default(),
)
.map_err(|err| GitError::Io(err.to_string()))?;
}
sley_config::raw_edit::RawEditOutcome::NothingSet => {}
}
}
Ok(())
}
fn relative_path_between(from_dir: &Path, to_path: &Path) -> PathBuf {
let from = normalize_absolute_path_lexically(from_dir);
let to = normalize_absolute_path_lexically(to_path);
let from_components = from.components().collect::<Vec<_>>();
let to_components = to.components().collect::<Vec<_>>();
let mut common = 0usize;
while common < from_components.len()
&& common < to_components.len()
&& from_components[common] == to_components[common]
{
common += 1;
}
if common == 0 {
return to;
}
let mut relative = PathBuf::new();
for component in &from_components[common..] {
if matches!(component, std::path::Component::Normal(_)) {
relative.push("..");
}
}
for component in &to_components[common..] {
match component {
std::path::Component::Normal(value) => relative.push(value),
std::path::Component::ParentDir => relative.push(".."),
std::path::Component::CurDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => {}
}
}
if relative.as_os_str().is_empty() {
relative.push(".");
}
relative
}
fn gitfile_path_value(path: &Path) -> String {
let mut parts = Vec::new();
let mut absolute = false;
for component in path.components() {
match component {
std::path::Component::Prefix(prefix) => {
parts.push(prefix.as_os_str().to_string_lossy().into_owned());
}
std::path::Component::RootDir => absolute = true,
std::path::Component::CurDir => parts.push(".".to_string()),
std::path::Component::ParentDir => parts.push("..".to_string()),
std::path::Component::Normal(value) => {
parts.push(value.to_string_lossy().into_owned());
}
}
}
let path = parts.join("/");
if absolute { format!("/{path}") } else { path }
}
fn contains_nested_git_dir(root: &Path) -> bool {
let Ok(entries) = fs::read_dir(root) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if entry.file_name() == ".git" && path.is_dir() {
return true;
}
if path.is_dir() && contains_nested_git_dir(&path) {
return true;
}
}
false
}
fn print_rm_error_files(
files: &[&[u8]],
singular: &str,
plural: &str,
hint: &str,
show_hints: bool,
errs: &mut bool,
) {
if files.is_empty() {
return;
}
let mut message = String::from(if files.len() == 1 { singular } else { plural });
for path in files {
message.push_str("\n ");
message.push_str(&String::from_utf8_lossy(path));
}
if show_hints {
message.push_str(hint);
}
eprintln!("error: {message}");
*errs = true;
}
pub fn move_index_and_worktree_path(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
source: &Path,
destination: &Path,
options: MoveOptions,
) -> Result<MoveResult> {
let worktree_root = worktree_root.as_ref();
let git_dir = git_dir.as_ref();
let index_path = repository_index_path(git_dir);
let mut index = if index_path.exists() {
Index::parse(&fs::read(&index_path)?, format)?
} else {
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
}
};
let source_absolute = if source.is_absolute() {
source.to_path_buf()
} else {
worktree_root.join(source)
};
let source_absolute = normalize_absolute_path_lexically(&source_absolute);
let destination_absolute = if destination.is_absolute() {
destination.to_path_buf()
} else {
worktree_root.join(destination)
};
let destination_has_trailing_separator = path_has_trailing_separator(&destination_absolute);
let destination_absolute = normalize_absolute_path_lexically(&destination_absolute);
let mut destination_absolute = if destination_absolute.is_dir() {
let Some(file_name) = source_absolute.file_name() else {
return Err(GitError::InvalidPath(format!(
"invalid source path {}",
source.display()
)));
};
destination_absolute.join(file_name)
} else {
destination_absolute
};
if path_has_trailing_separator(&destination_absolute)
&& !destination_absolute.exists()
&& source_absolute.is_dir()
&& let (Some(parent), Some(file_name)) = (
destination_absolute.parent(),
destination_absolute.file_name(),
)
{
destination_absolute = parent.join(file_name);
}
let source_relative = source_absolute.strip_prefix(worktree_root).map_err(|_| {
GitError::InvalidPath(format!("path {} is outside worktree", source.display()))
})?;
let destination_relative = destination_absolute
.strip_prefix(worktree_root)
.map_err(|_| {
GitError::InvalidPath(format!(
"path {} is outside worktree",
destination.display()
))
})?;
let source_path = git_path_bytes(source_relative)?;
let destination_path = git_path_bytes(destination_relative)?;
if destination_has_trailing_separator
&& !destination_absolute.is_dir()
&& !source_absolute.is_dir()
{
if options.skip_errors {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: true,
fatal: None,
details: Vec::new(),
});
}
let mut destination = String::from_utf8_lossy(&destination_path).into_owned();
destination.push('/');
if options.dry_run {
let fatal = format!(
"fatal: destination directory does not exist, source={}, destination={destination}",
String::from_utf8_lossy(&source_path),
);
return Ok(MoveResult {
source: source_path,
destination: destination.clone().into_bytes(),
skipped: false,
fatal: Some(fatal),
details: Vec::new(),
});
}
eprintln!(
"fatal: destination directory does not exist, source={}, destination={destination}",
String::from_utf8_lossy(&source_path),
);
return Err(GitError::Exit(128));
}
let directory_prefix = {
let mut prefix = source_path.clone();
prefix.push(b'/');
prefix
};
let directory_entries: Vec<_> = index
.entries
.iter()
.filter(|entry| entry.path.as_bytes().starts_with(&directory_prefix))
.cloned()
.collect();
let source_is_conflicted = index.entries.iter().any(|entry| {
(entry.path.as_bytes() == source_path.as_slice()
|| entry.path.as_bytes().starts_with(&directory_prefix))
&& entry.stage() != Stage::Normal
});
if source_is_conflicted {
if options.skip_errors {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: true,
fatal: None,
details: Vec::new(),
});
}
if options.dry_run {
let fatal = format!(
"fatal: conflicted, source={}, destination={}",
String::from_utf8_lossy(&source_path),
String::from_utf8_lossy(&destination_path)
);
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: Some(fatal),
details: Vec::new(),
});
}
eprintln!(
"fatal: conflicted, source={}, destination={}",
String::from_utf8_lossy(&source_path),
String::from_utf8_lossy(&destination_path)
);
return Err(GitError::Exit(128));
}
let source_position = index
.entries
.iter()
.position(|entry| entry.path == source_path && entry.stage() == Stage::Normal);
let source_is_tracked = !directory_entries.is_empty() || source_position.is_some();
if !source_is_tracked {
if options.skip_errors {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: true,
fatal: None,
details: Vec::new(),
});
}
let source_kind = if source_absolute.exists() {
"not under version control"
} else {
"bad source"
};
if options.dry_run {
let fatal = format!(
"fatal: {source_kind}, source={}, destination={}",
String::from_utf8_lossy(&source_path),
String::from_utf8_lossy(&destination_path)
);
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: Some(fatal),
details: Vec::new(),
});
}
eprintln!(
"fatal: {source_kind}, source={}, destination={}",
String::from_utf8_lossy(&source_path),
String::from_utf8_lossy(&destination_path)
);
return Err(GitError::Exit(128));
}
if destination_absolute.exists() {
if !options.force {
if options.skip_errors {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: true,
fatal: None,
details: Vec::new(),
});
}
if options.dry_run {
let fatal = format!(
"fatal: destination exists, source={}, destination={}",
String::from_utf8_lossy(&source_path),
String::from_utf8_lossy(&destination_path)
);
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: Some(fatal),
details: Vec::new(),
});
}
eprintln!(
"fatal: destination exists, source={}, destination={}",
String::from_utf8_lossy(&source_path),
String::from_utf8_lossy(&destination_path)
);
return Err(GitError::Exit(128));
}
if !options.dry_run && destination_absolute.is_dir() {
fs::remove_dir_all(&destination_absolute)?;
} else if !options.dry_run {
fs::remove_file(&destination_absolute)?;
}
}
let gitlink_moves = if options.dry_run {
Vec::new()
} else if !directory_entries.is_empty() {
directory_entries
.iter()
.filter(|entry| sley_index::is_gitlink(entry.mode))
.map(|entry| {
let suffix = &entry.path.as_bytes()[source_path.len()..];
let mut destination = destination_path.clone();
destination.extend_from_slice(suffix);
GitmodulesMove {
source: entry.path.as_bytes().to_vec(),
destination,
}
})
.collect::<Vec<_>>()
} else if let Some(position) = source_position {
let entry = &index.entries[position];
if sley_index::is_gitlink(entry.mode) {
vec![GitmodulesMove {
source: source_path.clone(),
destination: destination_path.clone(),
}]
} else {
Vec::new()
}
} else {
Vec::new()
};
let gitmodules_move = prepare_gitmodules_for_moved_gitlinks(
worktree_root,
git_dir,
format,
&index.entries,
&gitlink_moves,
)?;
let gitlink_gitdir_moves = prepare_moved_gitlink_gitdirs(worktree_root, &gitlink_moves)?;
if !directory_entries.is_empty() {
let details: Vec<_> = directory_entries
.iter()
.map(|entry| {
let suffix = &entry.path.as_bytes()[source_path.len()..];
let mut destination = destination_path.clone();
destination.extend_from_slice(suffix);
MoveDetail {
source: entry.path.as_bytes().to_vec(),
destination,
skipped: false,
}
})
.collect();
if options.dry_run {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: None,
details,
});
}
fs::rename(&source_absolute, &destination_absolute)?;
apply_moved_gitlink_gitdirs(&gitlink_gitdir_moves)?;
let moved_paths: Vec<_> = details
.iter()
.map(|detail| detail.destination.clone())
.collect();
index.entries.retain(|entry| {
!entry.path.as_bytes().starts_with(&directory_prefix)
&& !moved_paths
.iter()
.any(|m| m.as_slice() == entry.path.as_bytes())
});
for (source_entry, detail) in directory_entries.into_iter().zip(details.iter()) {
let relative_path = git_path_to_relative_path(&detail.destination)?;
let metadata = fs::metadata(worktree_root.join(relative_path))?;
let mut destination_entry =
index_entry_from_metadata(detail.destination.clone(), source_entry.oid, &metadata);
destination_entry.mode = source_entry.mode;
index.entries.push(destination_entry);
}
if let Some(edited) = gitmodules_move {
apply_prepared_gitmodules_move(
worktree_root,
git_dir,
format,
&mut index.entries,
edited,
)?;
}
index
.entries
.sort_by(|left, right| left.path.cmp(&right.path));
index.extensions.clear();
write_repository_index_ref(git_dir, format, &index)?;
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: None,
details,
});
}
let position = source_position.expect("tracked non-directory source must have an index entry");
if options.dry_run {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: None,
details: Vec::new(),
});
}
if let Some(parent) = destination_absolute.parent()
&& !parent.exists()
{
if options.skip_errors {
return Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: true,
fatal: None,
details: Vec::new(),
});
}
eprintln!(
"fatal: renaming '{}' failed: No such file or directory",
String::from_utf8_lossy(&source_path)
);
return Err(GitError::Exit(128));
}
fs::rename(&source_absolute, &destination_absolute)?;
apply_moved_gitlink_gitdirs(&gitlink_gitdir_moves)?;
let source_entry = index.entries.remove(position);
let mut destination_entry = source_entry;
destination_entry.path = destination_path.clone().into();
destination_entry.refresh_name_length();
index.entries.retain(|entry| entry.path != destination_path);
index.entries.push(destination_entry);
if let Some(edited) = gitmodules_move {
apply_prepared_gitmodules_move(worktree_root, git_dir, format, &mut index.entries, edited)?;
}
index
.entries
.sort_by(|left, right| left.path.cmp(&right.path));
index.extensions.clear();
write_repository_index_ref(git_dir, format, &index)?;
Ok(MoveResult {
source: source_path,
destination: destination_path,
skipped: false,
fatal: None,
details: Vec::new(),
})
}
fn restore_index_entry(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
entry: &IndexEntry,
smudge_config: Option<&GitConfig>,
stat_cache: Option<&IndexStatCache>,
) -> Result<Option<IndexEntry>> {
if sley_index::is_gitlink(entry.mode) {
let dir_path = worktree_path(worktree_root, entry.path.as_bytes())?;
materialize_gitlink_dir(worktree_root, &dir_path)?;
return Ok(None);
}
let file_path = worktree_path(worktree_root, entry.path.as_bytes())?;
if let Some(stat_cache) = stat_cache {
if let Ok(metadata) = fs::symlink_metadata(&file_path) {
if stat_cache
.reuse_index_entry_for_checkout(entry, &metadata)
.is_some()
{
return Ok(None);
}
}
}
let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
let body: Cow<'_, [u8]> = match smudge_config {
Some(config) => {
let checks = smudge_attribute_checks_from_index(
worktree_root,
git_dir,
format,
entry.path.as_bytes(),
)?;
apply_smudge_filter_with_attributes_cow_format(
config,
&checks,
entry.path.as_bytes(),
&object.body,
format,
)?
}
None => Cow::Borrowed(&object.body),
};
prepare_blob_parent_dirs(worktree_root, &file_path)?;
remove_existing_worktree_path(&file_path)?;
fs::write(&file_path, &body)?;
set_worktree_file_mode(&file_path, entry.mode)?;
let metadata = fs::symlink_metadata(&file_path)?;
Ok(Some(index_entry_with_refreshed_stat(entry, &metadata)))
}
fn index_entry_with_refreshed_stat(entry: &IndexEntry, metadata: &fs::Metadata) -> IndexEntry {
let mut refreshed = index_entry_from_metadata(entry.path.clone(), entry.oid, metadata);
refreshed.mode = entry.mode;
refreshed.flags = entry.flags;
refreshed.flags_extended = entry.flags_extended;
refreshed
}
fn restored_head_index_entry(
_worktree_root: &Path,
_db: &FileObjectDatabase,
path: &[u8],
entry: &TrackedEntry,
) -> Result<IndexEntry> {
Ok(IndexEntry {
ctime_seconds: 0,
ctime_nanoseconds: 0,
mtime_seconds: 0,
mtime_nanoseconds: 0,
dev: 0,
ino: 0,
mode: entry.mode,
uid: 0,
gid: 0,
size: 0,
oid: entry.oid,
flags: path.len().min(0x0fff) as u16,
flags_extended: 0,
path: BString::from(path),
})
}
fn restore_head_entry_to_worktree(
worktree_root: &Path,
db: &FileObjectDatabase,
path: &[u8],
entry: &TrackedEntry,
) -> Result<()> {
materialize_tree_entry(db, worktree_root, path, entry)?;
Ok(())
}
fn restore_head_entry_to_worktree_and_index(
worktree_root: &Path,
db: &FileObjectDatabase,
path: &[u8],
entry: &TrackedEntry,
) -> Result<IndexEntry> {
materialize_tree_entry(db, worktree_root, path, entry)
}
fn index_has_entry_under(entries: &[IndexEntry], directory: &[u8]) -> bool {
entries
.iter()
.any(|entry| index_entry_is_under_path(entry.path.as_bytes(), directory))
}
fn index_entry_is_under_path(entry_path: &[u8], directory: &[u8]) -> bool {
if directory.is_empty() {
return true;
}
entry_path
.strip_prefix(directory)
.and_then(|rest| rest.strip_prefix(b"/"))
.is_some()
}
fn index_entry_from_metadata(
path: impl Into<BString>,
oid: ObjectId,
metadata: &fs::Metadata,
) -> IndexEntry {
let modified = metadata.modified().ok();
let duration = modified
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.unwrap_or_default();
let mode = file_mode(metadata);
let path = path.into();
let flags = path.len().min(0x0fff) as u16;
let mut entry = IndexEntry {
ctime_seconds: duration.as_secs().min(u32::MAX as u64) as u32,
ctime_nanoseconds: duration.subsec_nanos(),
mtime_seconds: duration.as_secs().min(u32::MAX as u64) as u32,
mtime_nanoseconds: duration.subsec_nanos(),
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
size: index_size_from_metadata(metadata),
oid,
flags,
flags_extended: 0,
path,
};
apply_unix_metadata_to_index_entry(&mut entry, metadata);
entry
}
fn index_entry_from_metadata_with_filemode(
path: impl Into<BString>,
oid: ObjectId,
metadata: &fs::Metadata,
trust_filemode: bool,
) -> IndexEntry {
let mut entry = index_entry_from_metadata(path, oid, metadata);
entry.mode = file_mode_with_trust(metadata, trust_filemode);
entry
}
fn trust_executable_bit_from_git_dir(git_dir: &Path, config_parameters_env: Option<&str>) -> bool {
sley_config::read_repo_config(git_dir, config_parameters_env)
.ok()
.as_ref()
.map(trust_executable_bit)
.unwrap_or(true)
}
fn trust_executable_bit(config: &GitConfig) -> bool {
config.get_bool("core", None, "filemode").unwrap_or(true)
}
fn trust_symlinks_from_git_dir(git_dir: &Path, config_parameters_env: Option<&str>) -> bool {
sley_config::read_repo_config(git_dir, config_parameters_env)
.ok()
.as_ref()
.map(trust_symlinks)
.unwrap_or(true)
}
fn trust_symlinks(config: &GitConfig) -> bool {
config.get_bool("core", None, "symlinks").unwrap_or(true)
}
fn preferred_unmerged_mode_for_untrusted_worktree(
entries: &[IndexEntry],
trust_filemode: bool,
trust_symlinks: bool,
) -> Option<u32> {
if trust_filemode && trust_symlinks {
return None;
}
let preferred = entries
.iter()
.find(|entry| entry.stage() == Stage::Ours)
.or_else(|| entries.iter().find(|entry| entry.stage() == Stage::Base))?;
if (!trust_symlinks && preferred.mode == 0o120000)
|| (!trust_filemode && matches!(preferred.mode, 0o100644 | 0o100755))
{
Some(preferred.mode)
} else {
None
}
}
fn file_mode_with_trust(metadata: &fs::Metadata, trust_filemode: bool) -> u32 {
if trust_filemode {
file_mode(metadata)
} else {
0o100644
}
}
#[cfg(unix)]
fn apply_unix_metadata_to_index_entry(entry: &mut IndexEntry, metadata: &fs::Metadata) {
use std::os::unix::fs::MetadataExt;
entry.ctime_seconds = metadata.ctime().min(u32::MAX as i64).max(0) as u32;
entry.ctime_nanoseconds = metadata.ctime_nsec().min(u32::MAX as i64).max(0) as u32;
entry.dev = metadata.dev() as u32;
entry.ino = metadata.ino() as u32;
entry.uid = metadata.uid();
entry.gid = metadata.gid();
}
#[cfg(not(unix))]
fn apply_unix_metadata_to_index_entry(_entry: &mut IndexEntry, _metadata: &fs::Metadata) {}
fn index_size_from_metadata(metadata: &fs::Metadata) -> u32 {
metadata.len().min(u32::MAX as u64) as u32
}
fn read_expected_object(
db: &FileObjectDatabase,
oid: &ObjectId,
expected: ObjectType,
) -> Result<std::sync::Arc<EncodedObject>> {
let object = db
.read_object(oid)
.map_err(|err| expect_missing_object_kind(err, *oid, missing_kind_for_type(expected)))?;
if object.object_type != expected {
return Err(GitError::InvalidObject(format!(
"expected {} {}, found {}",
expected.as_str(),
oid,
object.object_type.as_str()
)));
}
Ok(object)
}
fn expect_missing_object_kind(
err: GitError,
oid: ObjectId,
expected: MissingObjectKind,
) -> GitError {
match err.not_found_kind() {
Some(sley_core::NotFoundKind::Object { .. }) => GitError::object_kind_not_found_in(
oid,
expected,
MissingObjectContext::WorktreeMaterialize,
),
_ => err,
}
}
fn missing_kind_for_type(object_type: ObjectType) -> MissingObjectKind {
match object_type {
ObjectType::Blob => MissingObjectKind::Blob,
ObjectType::Tree => MissingObjectKind::Tree,
ObjectType::Commit => MissingObjectKind::Commit,
ObjectType::Tag => MissingObjectKind::Tag,
}
}
fn read_commit(db: &FileObjectDatabase, format: ObjectFormat, oid: &ObjectId) -> Result<Commit> {
let object = read_expected_object(db, oid, ObjectType::Commit)?;
Commit::parse(format, &object.body)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TrackedEntry {
mode: u32,
oid: ObjectId,
}
#[derive(Debug, Clone, Default)]
struct IndexStatCache {
entries: HashMap<Vec<u8>, IndexEntry>,
index_mtime: Option<(u64, u64)>,
}
impl IndexStatCache {
fn from_index(index: &Index, index_path: &Path) -> Self {
let index_mtime = fs::metadata(index_path)
.ok()
.and_then(|metadata| file_mtime_parts(&metadata));
Self::from_index_mtime(index, index_mtime)
}
fn from_index_mtime(index: &Index, index_mtime: Option<(u64, u64)>) -> Self {
IndexStatCache {
entries: stage0_index_entries(index),
index_mtime,
}
}
fn from_index_mtime_only(index_mtime: Option<(u64, u64)>) -> Self {
IndexStatCache {
entries: HashMap::new(),
index_mtime,
}
}
fn is_racily_clean(&self, entry: &IndexEntry) -> bool {
let Some(index_mtime) = self.index_mtime else {
return true;
};
if index_mtime == (0, 0) {
return true;
}
let entry_mtime = (
u64::from(entry.mtime_seconds),
u64::from(entry.mtime_nanoseconds),
);
if entry_mtime == (0, 0) {
return true;
}
index_mtime <= entry_mtime
}
fn is_racily_clean_ref(&self, entry: &IndexEntryRef<'_>) -> bool {
let Some(index_mtime) = self.index_mtime else {
return true;
};
if index_mtime == (0, 0) {
return true;
}
let entry_mtime = (
u64::from(entry.mtime_seconds),
u64::from(entry.mtime_nanoseconds),
);
if entry_mtime == (0, 0) {
return true;
}
index_mtime <= entry_mtime
}
fn contains(&self, git_path: &[u8]) -> bool {
self.entries.contains_key(git_path)
}
fn tracked_entry(&self, git_path: &[u8]) -> Option<TrackedEntry> {
self.entries.get(git_path).map(|entry| TrackedEntry {
mode: entry.mode,
oid: entry.oid,
})
}
fn index_entry(&self, git_path: &[u8]) -> Option<&IndexEntry> {
self.entries.get(git_path)
}
fn reuse_tracked_entry(
&self,
git_path: &[u8],
worktree_metadata: &fs::Metadata,
) -> Option<TrackedEntry> {
let entry = self.entries.get(git_path)?;
self.reuse_index_entry(entry, worktree_metadata)
}
fn reuse_index_entry(
&self,
entry: &IndexEntry,
worktree_metadata: &fs::Metadata,
) -> Option<TrackedEntry> {
if sley_index::is_gitlink(entry.mode) {
return match sley_index::gitlink_stat_verdict(worktree_metadata) {
sley_index::GitlinkStatVerdict::Populated => Some(TrackedEntry {
mode: entry.mode,
oid: entry.oid,
}),
sley_index::GitlinkStatVerdict::TypeChanged => None,
};
}
if entry.mode != worktree_entry_mode(worktree_metadata) {
return None;
}
if !worktree_entry_is_uptodate(entry, worktree_metadata) {
return None;
}
if self.is_racily_clean(entry) {
return None;
}
Some(TrackedEntry {
mode: entry.mode,
oid: entry.oid,
})
}
fn reuse_index_entry_for_checkout(
&self,
entry: &IndexEntry,
worktree_metadata: &fs::Metadata,
) -> Option<TrackedEntry> {
if let Some(tracked) = self.reuse_index_entry(entry, worktree_metadata) {
return Some(tracked);
}
if u64::from(entry.size) != 0 || worktree_metadata.len() == 0 {
return None;
}
if entry.mode != worktree_entry_mode(worktree_metadata) {
return None;
}
let (mtime_seconds, mtime_nanoseconds) = file_mtime_parts(worktree_metadata)?;
if u64::from(entry.mtime_seconds) != mtime_seconds
|| u64::from(entry.mtime_nanoseconds) != mtime_nanoseconds
{
return None;
}
if self.is_racily_clean(entry) {
return None;
}
Some(TrackedEntry {
mode: entry.mode,
oid: entry.oid,
})
}
fn reuse_index_entry_ref(
&self,
entry: &IndexEntryRef<'_>,
worktree_metadata: &fs::Metadata,
) -> Option<TrackedEntry> {
if sley_index::is_gitlink(entry.mode) {
return match sley_index::gitlink_stat_verdict(worktree_metadata) {
sley_index::GitlinkStatVerdict::Populated => Some(TrackedEntry {
mode: entry.mode,
oid: entry.oid,
}),
sley_index::GitlinkStatVerdict::TypeChanged => None,
};
}
if entry.mode != worktree_entry_mode(worktree_metadata) {
return None;
}
if !worktree_entry_ref_is_uptodate(entry, worktree_metadata) {
return None;
}
if self.is_racily_clean_ref(entry) {
return None;
}
Some(TrackedEntry {
mode: entry.mode,
oid: entry.oid,
})
}
fn gitlink_entry(&self, git_path: &[u8]) -> Option<&IndexEntry> {
self.entries
.get(git_path)
.filter(|entry| sley_index::is_gitlink(entry.mode))
}
}
fn read_index_entries(
git_dir: &Path,
format: ObjectFormat,
) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
let db = FileObjectDatabase::from_git_dir(git_dir, format);
Ok(read_index_entries_with_stat_cache(git_dir, format, &db)?.0)
}
fn read_all_index_paths(git_dir: &Path, format: ObjectFormat) -> Result<BTreeSet<Vec<u8>>> {
let index_path = repository_index_path(git_dir);
let bytes = match fs::read(index_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(err) => return Err(err.into()),
};
let index = Index::parse(&bytes, format)?;
Ok(index
.entries
.into_iter()
.map(|entry| entry.path.into_bytes())
.collect())
}
fn resolve_head_tree_oid(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
) -> Result<Option<ObjectId>> {
let Some(commit_oid) = resolve_head_commit_oid(git_dir, format)? else {
return Ok(None);
};
if let Some(tree_oid) = sley_rev::commit_graph_tree_oid(git_dir, format, &commit_oid)? {
return Ok(Some(tree_oid));
}
let object = read_expected_object(db, &commit_oid, ObjectType::Commit)?;
let commit = Commit::parse_ref(format, &object.body)?;
Ok(Some(commit.tree))
}
fn resolve_head_commit_oid(git_dir: &Path, format: ObjectFormat) -> Result<Option<ObjectId>> {
let refs = FileRefStore::new(git_dir, format);
sley_refs::resolve_ref_peeled(&refs, "HEAD")
}
fn status_row_is_untracked_or_ignored(entry: ShortStatusRow<'_>) -> bool {
matches!((entry.index, entry.worktree), (b'?', b'?') | (b'!', b'!'))
}
fn checkout_switch_head_symbolic(
refs: &FileRefStore,
branch_ref: String,
committer: Vec<u8>,
branch: &str,
old_oid: Option<ObjectId>,
new_oid: Option<ObjectId>,
) -> Result<()> {
let from = match refs.read_ref("HEAD") {
Ok(Some(RefTarget::Symbolic(name))) => name
.strip_prefix("refs/heads/")
.unwrap_or(&name)
.to_string(),
Ok(Some(RefTarget::Direct(oid))) => oid.to_hex(),
_ => "HEAD".to_string(),
};
let mut tx = refs.transaction();
let reflog = match (old_oid, new_oid) {
(Some(old_oid), Some(new_oid)) => Some(ReflogEntry {
old_oid,
new_oid,
committer,
message: format!("checkout: moving from {from} to {branch}").into_bytes(),
}),
_ => None,
};
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Symbolic(branch_ref),
reflog,
});
tx.commit()
}
fn cache_tree_is_valid(tree: &CacheTree) -> bool {
if tree.entry_count < 0 || tree.oid.is_none() {
return false;
}
tree.subtrees
.iter()
.all(|child| cache_tree_is_valid(&child.tree))
}
fn head_matches_index_from_cache_tree(
index: &Index,
format: ObjectFormat,
head_tree_oid: &ObjectId,
stage0_entry_count: usize,
) -> Result<bool> {
let cache_tree = match index.cache_tree(format) {
Ok(Some(cache_tree)) => cache_tree,
Ok(None) | Err(_) => return Ok(false),
};
if !cache_tree_is_valid(&cache_tree) {
return Ok(false);
}
let Some(root_oid) = cache_tree.oid.as_ref() else {
return Ok(false);
};
if root_oid != head_tree_oid {
return Ok(false);
}
Ok(cache_tree.entry_count as usize == stage0_entry_count)
}
fn head_matches_borrowed_index_from_cache_tree(
index: &BorrowedIndex<'_>,
format: ObjectFormat,
head_tree_oid: &ObjectId,
stage0_entry_count: usize,
) -> Result<bool> {
let cache_tree = match index.cache_tree(format) {
Ok(Some(cache_tree)) => cache_tree,
Ok(None) | Err(_) => return Ok(false),
};
if !cache_tree_is_valid(&cache_tree) {
return Ok(false);
}
let Some(root_oid) = cache_tree.oid.as_ref() else {
return Ok(false);
};
if root_oid != head_tree_oid {
return Ok(false);
}
Ok(cache_tree.entry_count as usize == stage0_entry_count)
}
fn read_index_entries_with_stat_cache(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
) -> Result<(BTreeMap<Vec<u8>, TrackedEntry>, IndexStatCache, bool)> {
let (index, stat_cache, head_matches_index) = read_index_with_stat_cache(git_dir, format, db)?;
let tracked = index_entries_from_index(index);
Ok((tracked, stat_cache, head_matches_index))
}
fn index_entries_from_index(index: Index) -> BTreeMap<Vec<u8>, TrackedEntry> {
index
.entries
.into_iter()
.filter(|entry| entry.stage() == Stage::Normal)
.map(|entry| {
(
entry.path.into_bytes(),
TrackedEntry {
mode: entry.mode,
oid: entry.oid,
},
)
})
.collect()
}
fn read_index_with_stat_cache(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
) -> Result<(Index, IndexStatCache, bool)> {
read_index_with_stat_cache_entries(git_dir, format, db, true)
}
fn read_index_with_stat_cache_entries(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
include_entries: bool,
) -> Result<(Index, IndexStatCache, bool)> {
let index_path = repository_index_path(git_dir);
let index_metadata = match fs::metadata(&index_path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok((
Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
},
IndexStatCache::default(),
false,
));
}
Err(err) => return Err(err.into()),
};
let index = sley_index::read_repository_index(git_dir, format)?;
let index_mtime = file_mtime_parts(&index_metadata);
let stage0_entry_count = index
.entries
.iter()
.filter(|entry| index_entry_stage(entry) == 0)
.count();
let stat_cache = if include_entries {
IndexStatCache::from_index_mtime(&index, index_mtime)
} else {
IndexStatCache::from_index_mtime_only(index_mtime)
};
let head_matches_index = match resolve_head_tree_oid(git_dir, format, db)? {
Some(head_tree_oid) => {
head_matches_index_from_cache_tree(&index, format, &head_tree_oid, stage0_entry_count)?
}
None => false,
};
Ok((index, stat_cache, head_matches_index))
}
fn head_tree_entries(
git_dir: &Path,
format: ObjectFormat,
db: &FileObjectDatabase,
) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
let refs = FileRefStore::new(git_dir, format);
let Some(head) = refs.read_ref("HEAD")? else {
return Ok(BTreeMap::new());
};
let commit_oid = match head {
RefTarget::Direct(oid) => Some(oid),
RefTarget::Symbolic(name) => match refs.read_ref(&name)? {
Some(RefTarget::Direct(oid)) => Some(oid),
_ => None,
},
};
let Some(commit_oid) = commit_oid else {
return Ok(BTreeMap::new());
};
let object = read_expected_object(db, &commit_oid, ObjectType::Commit)?;
let commit = Commit::parse_ref(format, &object.body)?;
let mut entries = BTreeMap::new();
collect_tree_entries(db, format, &commit.tree, &mut entries)?;
Ok(entries)
}
fn tree_entries(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
let mut entries = BTreeMap::new();
collect_tree_entries(db, format, tree_oid, &mut entries)?;
Ok(entries)
}
fn collect_tree_entries(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
entries: &mut BTreeMap<Vec<u8>, TrackedEntry>,
) -> Result<()> {
for (path, (mode, oid)) in sley_diff_merge::flatten_tree(db, format, tree_oid)? {
entries.insert(path, TrackedEntry { mode, oid });
}
Ok(())
}
fn worktree_entries_with_stat_cache(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
stat_cache: Option<&IndexStatCache>,
tracked_paths: Option<&BTreeSet<Vec<u8>>>,
ignores: Option<&mut IgnoreMatcher>,
) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
Ok(worktree_entries_with_submodule_dirt(
worktree_root,
git_dir,
format,
stat_cache,
tracked_paths,
ignores,
)?
.0)
}
type WorktreeEntriesWithDirt = (BTreeMap<Vec<u8>, TrackedEntry>, BTreeMap<Vec<u8>, u8>);
type StatusWorktreeSnapshot = (
BTreeMap<Vec<u8>, TrackedEntry>,
BTreeMap<Vec<u8>, u8>,
HashSet<Vec<u8>>,
);
fn worktree_entries_with_submodule_dirt(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
stat_cache: Option<&IndexStatCache>,
tracked_paths: Option<&BTreeSet<Vec<u8>>>,
ignores: Option<&mut IgnoreMatcher>,
) -> Result<WorktreeEntriesWithDirt> {
let mut entries = BTreeMap::new();
let mut submodule_dirt_map = BTreeMap::new();
let mut tracked_presence = HashSet::new();
let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
let mut attr_matcher = AttributeMatcher::from_worktree_base(worktree_root);
let attr_requested = filter_attribute_names();
let mut context = WorktreeEntriesWalk {
git_dir,
format,
config: &config,
matcher: &mut attr_matcher,
requested: &attr_requested,
stat_cache,
known_tracked_paths: tracked_paths,
tracked_paths,
ignores,
entries: &mut entries,
submodule_dirt: &mut submodule_dirt_map,
tracked_presence: &mut tracked_presence,
record_clean_tracked: true,
};
collect_worktree_entries(&mut context, worktree_root, &[])?;
Ok((entries, submodule_dirt_map))
}
fn status_worktree_entries_with_submodule_dirt(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
stat_cache: &IndexStatCache,
known_tracked_paths: Option<&BTreeSet<Vec<u8>>>,
tracked_paths: Option<&BTreeSet<Vec<u8>>>,
ignores: Option<&mut IgnoreMatcher>,
) -> Result<StatusWorktreeSnapshot> {
let mut entries = BTreeMap::new();
let mut submodule_dirt_map = BTreeMap::new();
let mut tracked_presence = HashSet::new();
let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
let mut attr_matcher = AttributeMatcher::from_worktree_base(worktree_root);
let attr_requested = filter_attribute_names();
let mut context = WorktreeEntriesWalk {
git_dir,
format,
config: &config,
matcher: &mut attr_matcher,
requested: &attr_requested,
stat_cache: Some(stat_cache),
known_tracked_paths,
tracked_paths,
ignores,
entries: &mut entries,
submodule_dirt: &mut submodule_dirt_map,
tracked_presence: &mut tracked_presence,
record_clean_tracked: false,
};
collect_worktree_entries(&mut context, worktree_root, &[])?;
Ok((entries, submodule_dirt_map, tracked_presence))
}
fn worktree_entry_for_git_path(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
git_path: &[u8],
expected_oid: &ObjectId,
expected_mode: u32,
stat_cache: Option<&IndexStatCache>,
) -> Result<Option<TrackedEntry>> {
let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(None);
}
Err(err) => return Err(err.into()),
};
if sley_index::is_gitlink(expected_mode) {
if !metadata.is_dir() {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(*expected_oid);
return Ok(Some(TrackedEntry {
mode: sley_index::GITLINK_MODE,
oid,
}));
}
if metadata.is_dir() {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
if !(metadata.is_file() || metadata.file_type().is_symlink()) {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
if let Some(tracked) =
stat_cache.and_then(|cache| cache.reuse_tracked_entry(git_path, &metadata))
{
return Ok(Some(tracked));
}
let mode = worktree_entry_mode(&metadata);
let body = if metadata.file_type().is_symlink() {
symlink_target_bytes(&absolute)?
} else {
let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
let body = fs::read(&absolute)?;
let clean = apply_clean_filter(worktree_root, git_dir, &config, git_path, &body)?;
let oid = match stat_cache.and_then(|cache| cache.index_entry(git_path)) {
Some(index_entry) => clean_filtered_oid_for_status(
format,
&body,
clean,
index_entry.oid,
index_entry.size,
&metadata,
)?,
None => EncodedObject::new(ObjectType::Blob, clean).object_id(format)?,
};
return Ok(Some(TrackedEntry { mode, oid }));
};
let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
Ok(Some(TrackedEntry { mode, oid }))
}
fn worktree_entry_for_index_entry_with_attributes(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entry: &IndexEntry,
stat_cache: &IndexStatCache,
clean_filter: &mut Option<TrackedOnlyCleanFilter>,
) -> Result<Option<TrackedEntry>> {
let git_path = index_entry.path.as_bytes();
let expected_mode = index_entry.mode;
let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(None);
}
Err(err) => return Err(err.into()),
};
let file_type = metadata.file_type();
if sley_index::is_gitlink(expected_mode) {
if !file_type.is_dir() {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(index_entry.oid);
return Ok(Some(TrackedEntry {
mode: sley_index::GITLINK_MODE,
oid,
}));
}
if file_type.is_dir() {
if expected_mode != 0o040000 {
return Ok(None);
}
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
if !(file_type.is_file() || file_type.is_symlink()) {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
if let Some(tracked) = stat_cache.reuse_index_entry(index_entry, &metadata) {
return Ok(Some(tracked));
}
let mode = worktree_entry_mode(&metadata);
let body = if file_type.is_symlink() {
symlink_target_bytes(&absolute)?
} else {
let body = fs::read(&absolute)?;
let clean_filter = tracked_only_clean_filter(clean_filter, worktree_root, git_dir);
clean_filter.read_attributes_for_path(worktree_root, git_path)?;
let checks =
clean_filter
.matcher
.attributes_for_path(git_path, &clean_filter.requested, false);
let clean =
apply_clean_filter_with_attributes(&clean_filter.config, &checks, git_path, &body)?;
let oid = clean_filtered_oid_for_status(
format,
&body,
clean,
index_entry.oid,
index_entry.size,
&metadata,
)?;
return Ok(Some(TrackedEntry { mode, oid }));
};
let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
Ok(Some(TrackedEntry { mode, oid }))
}
fn worktree_entry_for_index_entry_ref_with_attributes(
worktree_root: &Path,
git_dir: &Path,
format: ObjectFormat,
index_entry: &IndexEntryRef<'_>,
stat_cache: &IndexStatCache,
clean_filter: &mut Option<TrackedOnlyCleanFilter>,
) -> Result<Option<TrackedEntry>> {
let git_path = index_entry.path;
let expected_mode = index_entry.mode;
let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
let metadata = match fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(None);
}
Err(err) => return Err(err.into()),
};
let file_type = metadata.file_type();
if sley_index::is_gitlink(expected_mode) {
if !file_type.is_dir() {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(index_entry.oid);
return Ok(Some(TrackedEntry {
mode: sley_index::GITLINK_MODE,
oid,
}));
}
if file_type.is_dir() {
if expected_mode != 0o040000 {
return Ok(None);
}
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
if !(file_type.is_file() || file_type.is_symlink()) {
return Ok(Some(TrackedEntry {
mode: worktree_entry_mode(&metadata),
oid: ObjectId::null(format),
}));
}
if let Some(tracked) = stat_cache.reuse_index_entry_ref(index_entry, &metadata) {
return Ok(Some(tracked));
}
let mode = worktree_entry_mode(&metadata);
let body = if file_type.is_symlink() {
symlink_target_bytes(&absolute)?
} else {
let body = fs::read(&absolute)?;
let clean_filter = tracked_only_clean_filter(clean_filter, worktree_root, git_dir);
clean_filter.read_attributes_for_path(worktree_root, git_path)?;
let checks =
clean_filter
.matcher
.attributes_for_path(git_path, &clean_filter.requested, false);
let clean =
apply_clean_filter_with_attributes(&clean_filter.config, &checks, git_path, &body)?;
let oid = clean_filtered_oid_for_status(
format,
&body,
clean,
index_entry.oid,
index_entry.size,
&metadata,
)?;
return Ok(Some(TrackedEntry { mode, oid }));
};
let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
Ok(Some(TrackedEntry { mode, oid }))
}
fn clean_filtered_oid_for_status(
format: ObjectFormat,
raw_body: &[u8],
clean_body: Vec<u8>,
index_oid: ObjectId,
index_size: u32,
metadata: &fs::Metadata,
) -> Result<ObjectId> {
let clean_oid = EncodedObject::new(ObjectType::Blob, clean_body).object_id(format)?;
let metadata_size = index_size_from_metadata(metadata);
if clean_oid == index_oid && index_size != 0 && index_size != metadata_size {
return EncodedObject::new(ObjectType::Blob, raw_body.to_vec()).object_id(format);
}
Ok(clean_oid)
}
struct TrackedOnlyCleanFilter {
config: GitConfig,
matcher: AttributeMatcher,
requested: Vec<Vec<u8>>,
attribute_dirs: BTreeSet<Vec<u8>>,
}
impl TrackedOnlyCleanFilter {
fn read_attributes_for_path(&mut self, worktree_root: &Path, git_path: &[u8]) -> Result<()> {
self.read_attribute_dir(worktree_root, &[])?;
let mut prefix = Vec::new();
let mut parts = git_path.split(|byte| *byte == b'/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
break;
}
if !prefix.is_empty() {
prefix.push(b'/');
}
prefix.extend_from_slice(part);
self.read_attribute_dir(worktree_root, &prefix)?;
}
Ok(())
}
fn read_attribute_dir(&mut self, worktree_root: &Path, git_path: &[u8]) -> Result<()> {
if !self.attribute_dirs.insert(git_path.to_vec()) {
return Ok(());
}
let dir = if git_path.is_empty() {
worktree_root.to_path_buf()
} else {
worktree_root.join(repo_path_to_os_path(git_path)?)
};
read_dir_attribute_patterns(worktree_root, &dir, &mut self.matcher)
}
}
fn tracked_only_clean_filter<'a>(
clean_filter: &'a mut Option<TrackedOnlyCleanFilter>,
worktree_root: &Path,
git_dir: &Path,
) -> &'a mut TrackedOnlyCleanFilter {
if clean_filter.is_none() {
*clean_filter = Some(TrackedOnlyCleanFilter {
config: sley_config::read_repo_config(git_dir, None).unwrap_or_default(),
matcher: AttributeMatcher::from_worktree_base(worktree_root),
requested: filter_attribute_names(),
attribute_dirs: BTreeSet::new(),
});
}
clean_filter
.as_mut()
.expect("tracked-only clean filter initialized")
}
fn tracked_only_clean_filter_with_config<'a>(
clean_filter: &'a mut Option<TrackedOnlyCleanFilter>,
worktree_root: &Path,
config: &GitConfig,
) -> &'a mut TrackedOnlyCleanFilter {
if clean_filter.is_none() {
*clean_filter = Some(TrackedOnlyCleanFilter {
config: config.clone(),
matcher: AttributeMatcher::from_worktree_base(worktree_root),
requested: filter_attribute_names(),
attribute_dirs: BTreeSet::new(),
});
}
clean_filter
.as_mut()
.expect("tracked-only clean filter initialized")
}
struct WorktreeEntriesWalk<'a> {
git_dir: &'a Path,
format: ObjectFormat,
config: &'a GitConfig,
matcher: &'a mut AttributeMatcher,
requested: &'a [Vec<u8>],
stat_cache: Option<&'a IndexStatCache>,
known_tracked_paths: Option<&'a BTreeSet<Vec<u8>>>,
tracked_paths: Option<&'a BTreeSet<Vec<u8>>>,
ignores: Option<&'a mut IgnoreMatcher>,
entries: &'a mut BTreeMap<Vec<u8>, TrackedEntry>,
submodule_dirt: &'a mut BTreeMap<Vec<u8>, u8>,
tracked_presence: &'a mut HashSet<Vec<u8>>,
record_clean_tracked: bool,
}
impl WorktreeEntriesWalk<'_> {
fn mark_tracked_present(&mut self, git_path: &[u8]) {
self.tracked_presence.insert(git_path.to_vec());
}
fn tracked_entry_for(&self, git_path: &[u8]) -> Option<TrackedEntry> {
self.stat_cache
.and_then(|cache| cache.tracked_entry(git_path))
}
fn should_record_tracked_entry(&self, git_path: &[u8], entry: &TrackedEntry) -> bool {
self.record_clean_tracked
|| self
.tracked_entry_for(git_path)
.is_none_or(|tracked| tracked != *entry)
}
}
fn git_path_append_component(parent: &[u8], component: &std::ffi::OsStr) -> Vec<u8> {
let component = os_str_component_bytes(component);
let separator = usize::from(!parent.is_empty());
let mut path = Vec::with_capacity(parent.len() + separator + component.len());
if !parent.is_empty() {
path.extend_from_slice(parent);
path.push(b'/');
}
path.extend_from_slice(component.as_ref());
path
}
fn git_path_push_component(path: &mut Vec<u8>, component: &std::ffi::OsStr) -> usize {
let original_len = path.len();
let component = os_str_component_bytes(component);
if !path.is_empty() {
path.push(b'/');
}
path.extend_from_slice(component.as_ref());
original_len
}
#[cfg(unix)]
fn os_str_component_bytes(component: &std::ffi::OsStr) -> Cow<'_, [u8]> {
use std::os::unix::ffi::OsStrExt;
Cow::Borrowed(component.as_bytes())
}
#[cfg(not(unix))]
fn os_str_component_bytes(component: &std::ffi::OsStr) -> Cow<'_, [u8]> {
Cow::Owned(component.to_string_lossy().into_owned().into_bytes())
}
fn collect_worktree_entries(
context: &mut WorktreeEntriesWalk<'_>,
dir: &Path,
dir_git_path: &[u8],
) -> Result<()> {
if is_same_path(dir, context.git_dir) {
return Ok(());
}
read_dir_attribute_patterns_for_base(dir, dir_git_path, context.matcher)?;
if let Some(ignores) = context.ignores.as_deref_mut() {
read_dir_ignore_patterns_for_base(dir, dir_git_path, ignores)?;
}
let mut dir_entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
dir_entries.sort_by_key(|entry| entry.file_name());
for entry in dir_entries {
let file_name = entry.file_name();
let path = entry.path();
if is_dot_git_entry(&path) {
continue;
}
if is_same_path(&path, context.git_dir) {
continue;
}
let metadata = entry.metadata()?;
let git_path = git_path_append_component(dir_git_path, &file_name);
if context
.ignores
.as_ref()
.is_some_and(|ignores| ignores.is_ignored(&git_path, metadata.is_dir()))
{
let tracked = context.known_tracked_paths.is_some_and(|tracked_paths| {
if metadata.is_dir() {
tracked_paths_may_contain(tracked_paths, &git_path)
} else {
tracked_paths.contains(&git_path)
}
});
if !tracked {
continue;
}
if metadata.is_dir() {
collect_worktree_entries(context, &path, &git_path)?;
continue;
}
}
if metadata.is_dir() {
if let Some(index_entry) = context
.stat_cache
.and_then(|cache| cache.gitlink_entry(&git_path))
{
context.mark_tracked_present(&git_path);
let oid = sley_diff_merge::gitlink_head_oid(&path, context.format)
.unwrap_or(index_entry.oid);
let dirt = submodule_dirt(&path);
if dirt != 0 {
context.submodule_dirt.insert(git_path.clone(), dirt);
}
let tracked = TrackedEntry {
mode: sley_index::GITLINK_MODE,
oid,
};
if dirt != 0 || context.should_record_tracked_entry(&git_path, &tracked) {
context.entries.insert(git_path, tracked);
}
continue;
}
if is_nested_repository_boundary(&path, context.git_dir) {
if let Some(tracked_paths) = context.tracked_paths
&& !tracked_paths_may_contain(tracked_paths, &git_path)
{
continue;
}
context.entries.insert(
git_path,
TrackedEntry {
mode: 0o040000,
oid: ObjectId::null(context.format),
},
);
continue;
}
if let Some(tracked_paths) = context.tracked_paths
&& !tracked_paths_may_contain(tracked_paths, &git_path)
{
continue;
}
collect_worktree_entries(context, &path, &git_path)?;
} else if metadata.is_file() || metadata.file_type().is_symlink() {
if let Some(tracked_paths) = context.tracked_paths
&& !tracked_paths.contains(&git_path)
{
continue;
}
let entry_mode = worktree_entry_mode(&metadata);
if let Some(tracked) = context
.stat_cache
.and_then(|cache| cache.reuse_tracked_entry(&git_path, &metadata))
{
context.mark_tracked_present(&git_path);
if context.record_clean_tracked {
context.entries.insert(git_path, tracked);
}
continue;
}
if context
.stat_cache
.is_some_and(|cache| !cache.contains(&git_path))
{
context.entries.insert(
git_path,
TrackedEntry {
mode: entry_mode,
oid: ObjectId::null(context.format),
},
);
continue;
}
let body = if metadata.file_type().is_symlink() {
symlink_target_bytes(&path)?
} else {
let body = fs::read(&path)?;
let checks =
context
.matcher
.attributes_for_path(&git_path, context.requested, false);
let clean =
apply_clean_filter_with_attributes(context.config, &checks, &git_path, &body)?;
let oid = match context
.stat_cache
.and_then(|cache| cache.index_entry(&git_path))
{
Some(index_entry) => clean_filtered_oid_for_status(
context.format,
&body,
clean,
index_entry.oid,
index_entry.size,
&metadata,
)?,
None => {
EncodedObject::new(ObjectType::Blob, clean).object_id(context.format)?
}
};
let tracked = TrackedEntry {
mode: entry_mode,
oid,
};
if context
.stat_cache
.is_some_and(|cache| cache.contains(&git_path))
{
context.mark_tracked_present(&git_path);
if context.should_record_tracked_entry(&git_path, &tracked) {
context.entries.insert(git_path, tracked);
}
} else {
context.entries.insert(git_path, tracked);
}
continue;
};
let oid = EncodedObject::new(ObjectType::Blob, body).object_id(context.format)?;
let tracked = TrackedEntry {
mode: entry_mode,
oid,
};
if context
.stat_cache
.is_some_and(|cache| cache.contains(&git_path))
{
context.mark_tracked_present(&git_path);
if context.should_record_tracked_entry(&git_path, &tracked) {
context.entries.insert(git_path, tracked);
}
} else {
context.entries.insert(git_path, tracked);
}
}
}
Ok(())
}
fn tracked_paths_may_contain(tracked_paths: &BTreeSet<Vec<u8>>, directory: &[u8]) -> bool {
if tracked_paths.contains(directory) {
return true;
}
let mut prefix = Vec::with_capacity(directory.len() + 1);
prefix.extend_from_slice(directory);
prefix.push(b'/');
tracked_paths
.range::<[u8], _>((
std::ops::Bound::Included(prefix.as_slice()),
std::ops::Bound::Unbounded,
))
.next()
.is_some_and(|path| path.starts_with(&prefix))
}
fn is_same_path(left: &Path, right: &Path) -> bool {
left == right
}
fn is_dot_git_entry(path: &Path) -> bool {
path.file_name() == Some(std::ffi::OsStr::new(".git"))
}
fn is_nested_repository_boundary(path: &Path, git_dir: &Path) -> bool {
let dot_git = path.join(".git");
if dot_git.is_dir() {
if is_same_path(&dot_git, git_dir) {
return false;
}
return true;
}
sley_diff_merge::gitlink_git_dir(path).is_some_and(|embedded| !is_same_path(&embedded, git_dir))
}
fn active_repository_worktree_dir(path: &Path, git_dir: &Path) -> bool {
sley_diff_merge::gitlink_git_dir(path).is_some_and(|embedded| is_same_path(&embedded, git_dir))
}
fn is_embedded_git_internals(root: &Path, path: &Path) -> bool {
let Ok(relative) = path.strip_prefix(root) else {
return false;
};
let mut current = root.to_path_buf();
for component in relative.components() {
if matches!(component, std::path::Component::Normal(name) if name == ".git")
&& current != root
&& current.join(".git").is_dir()
{
return true;
}
current.push(component);
}
false
}
fn worktree_entry_mode(metadata: &fs::Metadata) -> u32 {
if metadata.file_type().is_symlink() {
0o120000
} else if metadata.is_dir() {
0o040000
} else {
file_mode(metadata)
}
}
fn worktree_path(root: &Path, path: &[u8]) -> Result<PathBuf> {
let text = std::str::from_utf8(path).map_err(|err| GitError::InvalidPath(err.to_string()))?;
let relative = PathBuf::from(text);
if relative.is_absolute()
|| relative.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::Prefix(_)
)
})
{
return Err(GitError::InvalidPath(format!(
"invalid worktree path {text}"
)));
}
Ok(root.join(relative))
}
fn remove_worktree_file(root: &Path, path: &[u8]) -> Result<()> {
let file = worktree_path(root, path)?;
if !file.exists() {
return Ok(());
}
if file.is_dir() {
match fs::remove_dir(&file) {
Ok(()) => prune_empty_parents(root, file.parent())?,
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
Err(err) => return Err(err.into()),
}
return Ok(());
}
fs::remove_file(&file)?;
prune_empty_parents(root, file.parent())?;
Ok(())
}
fn prune_empty_parents(root: &Path, mut dir: Option<&Path>) -> Result<()> {
while let Some(path) = dir {
if path == root || path_is_original_cwd(path) {
break;
}
match fs::remove_dir(path) {
Ok(()) => dir = path.parent(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => dir = path.parent(),
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
Err(err) => return Err(err.into()),
}
}
Ok(())
}
fn original_cwd_absolute() -> Option<PathBuf> {
let cwd = sley_core::original_cwd().or_else(|| env::current_dir().ok())?;
Some(fs::canonicalize(&cwd).unwrap_or(cwd))
}
fn path_is_original_cwd(path: &Path) -> bool {
let Some(cwd) = original_cwd_absolute() else {
return false;
};
let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
path == cwd
}
fn original_cwd_is_inside(path: &Path) -> bool {
let Some(cwd) = original_cwd_absolute() else {
return false;
};
let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
cwd == path || cwd.starts_with(&path)
}
fn refuse_if_current_working_directory_becomes_file(
worktree_root: &Path,
target_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
) -> Result<()> {
for (path, entry) in target_entries {
if sley_index::is_gitlink(entry.mode) || (entry.mode & 0o170000) == 0o040000 {
continue;
}
let path = worktree_path(worktree_root, path)?;
if path_is_original_cwd(&path)
&& fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir())
{
return refuse_remove_current_working_directory(&path);
}
}
Ok(())
}
fn refuse_remove_current_working_directory(path: &Path) -> Result<()> {
eprintln!(
"error: Refusing to remove the current working directory:\n{}",
path.display()
);
Err(GitError::Exit(128))
}
fn git_tree_entry_cmp(
left_name: &[u8],
left_mode: u32,
right_name: &[u8],
right_mode: u32,
) -> Ordering {
let shared = left_name.len().min(right_name.len());
let name_order = left_name[..shared].cmp(&right_name[..shared]);
if name_order != Ordering::Equal {
return name_order;
}
let left_end = left_name.len() == shared;
let right_end = right_name.len() == shared;
match (left_end, right_end) {
(true, true) => Ordering::Equal,
(true, false) => tree_name_terminator(left_mode).cmp(&right_name[shared]),
(false, true) => left_name[shared].cmp(&tree_name_terminator(right_mode)),
(false, false) => Ordering::Equal,
}
}
fn tree_name_terminator(mode: u32) -> u8 {
if mode == 0o040000 { b'/' } else { 0 }
}
#[cfg(unix)]
fn file_mode(metadata: &fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 != 0 {
0o100755
} else {
0o100644
}
}
#[cfg(not(unix))]
fn file_mode(_metadata: &fs::Metadata) -> u32 {
0o100644
}
#[cfg(unix)]
fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
use std::os::unix::ffi::OsStrExt;
let target = fs::read_link(path)?;
Ok(target.as_os_str().as_bytes().to_vec())
}
#[cfg(not(unix))]
fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
let target = fs::read_link(path)?;
Ok(target.to_string_lossy().replace('\\', "/").into_bytes())
}
fn git_path_bytes(path: &Path) -> Result<Vec<u8>> {
if path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::Prefix(_)
)
}) {
return Err(GitError::InvalidPath(format!(
"invalid index path {}",
path.display()
)));
}
Ok(path
.components()
.filter_map(|component| match component {
std::path::Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
.into_bytes())
}
fn normalize_absolute_path_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(_)
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => normalized.push(component.as_os_str()),
}
}
normalized
}
fn absolute_path_lexically(path: &Path, cwd: &Path) -> PathBuf {
if path.is_absolute() {
normalize_absolute_path_lexically(path)
} else {
normalize_absolute_path_lexically(&cwd.join(path))
}
}
fn repo_path_to_os_path(path: &[u8]) -> Result<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(path)))
}
#[cfg(not(unix))]
{
let path = std::str::from_utf8(path)
.map_err(|_| GitError::InvalidPath("index path is not utf8".into()))?;
Ok(path.split('/').collect())
}
}
fn git_path_to_relative_path(path: &[u8]) -> Result<PathBuf> {
let path = std::str::from_utf8(path)
.map_err(|err| GitError::InvalidPath(format!("invalid utf-8 index path: {err}")))?;
Ok(path.split('/').collect())
}
fn path_has_trailing_separator(path: &Path) -> bool {
path.as_os_str()
.to_string_lossy()
.ends_with(std::path::MAIN_SEPARATOR)
}
#[cfg(test)]
mod tests {
use super::*;
use sley_odb::ObjectReader;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn short_status(
worktree_root: impl AsRef<Path>,
git_dir: impl AsRef<Path>,
format: ObjectFormat,
) -> Result<Vec<ShortStatusEntry>> {
let mut entries = Vec::new();
stream_short_status(worktree_root, git_dir, format, |entry| {
entries.push(entry.to_owned_entry());
Ok(StreamControl::Continue)
})?;
Ok(entries)
}
#[test]
fn atomic_metadata_writer_writes_and_reports_stat() {
let root = temp_root();
let path = root.join(".git").join("HEAD");
let result = write_metadata_file_atomic(
&path,
b"ref: refs/heads/main\n",
AtomicMetadataWriteOptions::default(),
)
.expect("write metadata");
assert_eq!(
fs::read(&path).expect("read metadata"),
b"ref: refs/heads/main\n"
);
assert_eq!(result.path, path);
assert_eq!(result.len, b"ref: refs/heads/main\n".len() as u64);
assert!(result.mtime.is_some());
assert!(!path.with_file_name("HEAD.lock").exists());
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn atomic_metadata_writer_existing_lock_preserves_original() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(&git_dir).expect("create git dir");
let path = git_dir.join("HEAD");
let lock = git_dir.join("HEAD.lock");
fs::write(&path, b"ref: refs/heads/main\n").expect("write original");
fs::write(&lock, b"held\n").expect("write lock");
let err = write_metadata_file_atomic(
&path,
b"ref: refs/heads/other\n",
AtomicMetadataWriteOptions::default(),
)
.expect_err("held lock must fail");
assert!(matches!(err, GitError::Transaction(_)));
assert_eq!(
fs::read(&path).expect("read original"),
b"ref: refs/heads/main\n"
);
assert_eq!(fs::read(&lock).expect("read lock"), b"held\n");
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn convert_stats_ascii_classifies_eol_content() {
assert_eq!(convert_stats_ascii(b""), "none");
assert_eq!(convert_stats_ascii(b"abc"), "none");
assert_eq!(convert_stats_ascii(b"a\nb\n"), "lf");
assert_eq!(convert_stats_ascii(b"a\r\nb\r\n"), "crlf");
assert_eq!(convert_stats_ascii(b"a\r\nb\n"), "mixed");
assert_eq!(convert_stats_ascii(b"a\rb"), "-text");
assert_eq!(convert_stats_ascii(b"a\0b\n"), "-text");
assert_eq!(convert_stats_ascii(b"abc\n\x1a"), "lf");
}
fn attr_check(name: &[u8], state: Option<AttributeState>) -> AttributeCheck {
AttributeCheck {
attribute: name.to_vec(),
state,
}
}
#[test]
fn convert_attr_ascii_matches_git_attr_action() {
assert_eq!(convert_attr_ascii(&[]), "");
assert_eq!(
convert_attr_ascii(&[attr_check(b"text", Some(AttributeState::Set))]),
"text"
);
assert_eq!(
convert_attr_ascii(&[attr_check(b"text", Some(AttributeState::Unset))]),
"-text"
);
assert_eq!(
convert_attr_ascii(&[attr_check(
b"text",
Some(AttributeState::Value(b"auto".to_vec()))
)]),
"text=auto"
);
assert_eq!(
convert_attr_ascii(&[
attr_check(b"text", Some(AttributeState::Value(b"auto".to_vec()))),
attr_check(b"eol", Some(AttributeState::Value(b"crlf".to_vec()))),
]),
"text=auto eol=crlf"
);
assert_eq!(
convert_attr_ascii(&[
attr_check(b"text", Some(AttributeState::Value(b"auto".to_vec()))),
attr_check(b"eol", Some(AttributeState::Value(b"lf".to_vec()))),
]),
"text=auto eol=lf"
);
assert_eq!(
convert_attr_ascii(&[attr_check(
b"eol",
Some(AttributeState::Value(b"crlf".to_vec()))
)]),
"text eol=crlf"
);
assert_eq!(
convert_attr_ascii(&[attr_check(
b"eol",
Some(AttributeState::Value(b"lf".to_vec()))
)]),
"text eol=lf"
);
assert_eq!(
convert_attr_ascii(&[
attr_check(b"text", Some(AttributeState::Unset)),
attr_check(b"eol", Some(AttributeState::Value(b"crlf".to_vec()))),
]),
"-text"
);
}
#[test]
fn smudge_safety_guard_skips_irreversible_autocrlf() {
let auto = ContentFilterPlan {
text: TextDecision::Auto,
eol: EolConversion::Crlf,
ident: false,
driver: None,
};
assert!(auto.will_convert_lf_to_crlf(b"a\nb\n"));
assert!(!auto.will_convert_lf_to_crlf(b"a\r\nb\n")); assert!(!auto.will_convert_lf_to_crlf(b"a\nb\rc")); assert!(!auto.will_convert_lf_to_crlf(b"abc"));
let text = ContentFilterPlan {
text: TextDecision::Text,
eol: EolConversion::Crlf,
ident: false,
driver: None,
};
assert!(text.will_convert_lf_to_crlf(b"a\r\nb\nc\n"));
assert!(!text.will_convert_lf_to_crlf(b"a\r\nb\r\n")); }
fn ignore_matcher(patterns: &[&[u8]]) -> IgnoreMatcher {
let mut matcher = IgnoreMatcher::default();
let owned: Vec<Vec<u8>> = patterns.iter().map(|p| p.to_vec()).collect();
matcher.extend_patterns(&owned);
matcher
}
#[test]
fn ignore_match_kind_fast_paths_match_the_wildcard_engine() {
let matcher = ignore_matcher(&[b"Pods"]);
assert!(matcher.is_ignored(b"a/b/Pods", true));
assert!(matcher.is_ignored(b"Pods", false));
assert!(!matcher.is_ignored(b"Pods_not", false));
assert!(matches!(
classify_ignore_pattern(b"Pods"),
MatchKind::Literal
));
let matcher = ignore_matcher(&[b"*.log"]);
assert!(matcher.is_ignored(b"x.log", false));
assert!(matcher.is_ignored(b"a/b/x.log", false));
assert!(matcher.is_ignored(b".log", false));
assert!(!matcher.is_ignored(b"x.logx", false));
assert!(matches!(
classify_ignore_pattern(b"*.log"),
MatchKind::Suffix
));
let matcher = ignore_matcher(&[b"build*"]);
assert!(matcher.is_ignored(b"buildfoo", false));
assert!(matcher.is_ignored(b"a/build", false));
assert!(!matcher.is_ignored(b"xbuild", false));
assert!(matches!(
classify_ignore_pattern(b"build*"),
MatchKind::Prefix
));
}
#[test]
fn ignore_anchored_suffix_does_not_cross_slash() {
let matcher = ignore_matcher(&[b"/*.log"]);
assert!(matcher.is_ignored(b"x.log", false));
assert!(!matcher.is_ignored(b"sub/x.log", false));
let matcher = ignore_matcher(&[b"/foo"]);
assert!(matcher.is_ignored(b"foo", false));
assert!(!matcher.is_ignored(b"a/foo", false));
}
#[test]
fn ignore_anchored_directory_glob_matches_root_directory() {
let matcher = ignore_matcher(&[b"/tmp-*/"]);
assert!(matcher.is_ignored(b"tmp-info-only", true));
assert!(matcher.is_ignored(b"tmp-info-only/file.txt", false));
assert!(!matcher.is_ignored(b"nested/tmp-info-only", true));
assert!(!matcher.is_ignored(b"tmp-info-only", false));
}
#[test]
fn ignore_negated_directory_glob_does_not_reinclude_files() {
let matcher = ignore_matcher(&[b"data/**", b"!data/**/", b"!data/**/*.txt"]);
assert!(matcher.is_ignored(b"data/file", false));
assert!(matcher.is_ignored(b"data/data1/file1", false));
assert!(matcher.is_ignored(b"data/data2/file2", false));
assert!(!matcher.is_ignored(b"data/data1/file1.txt", false));
assert!(!matcher.is_ignored(b"data/data2/file2.txt", false));
assert!(!matcher.is_ignored(b"data/data1", true));
assert!(!matcher.is_ignored(b"data/data2", true));
}
#[test]
fn ignore_double_star_prefix_collapses_to_basename() {
let matcher = ignore_matcher(&[b"**/Pods"]);
assert!(matcher.is_ignored(b"a/b/Pods", true));
assert!(matcher.is_ignored(b"Pods", true));
assert!(!matcher.is_ignored(b"Pods_not", false));
let matcher = ignore_matcher(&[b"**/*.jks"]);
assert!(matcher.is_ignored(b"x.jks", false));
assert!(matcher.is_ignored(b"a/deep/y.jks", false));
assert!(!matcher.is_ignored(b"x.jksx", false));
let matcher = ignore_matcher(&[b"**/Flutter/ephemeral"]);
assert!(matcher.is_ignored(b"Flutter/ephemeral", true));
assert!(matcher.is_ignored(b"a/Flutter/ephemeral", true));
assert!(!matcher.is_ignored(b"Flutter/other", true));
assert!(matches!(
classify_ignore_pattern(b"**/Flutter/ephemeral"),
MatchKind::PathSuffix
));
}
#[test]
fn ignore_slash_glob_literal_basename_bucket_preserves_matches() {
let matcher = ignore_matcher(&[b"**/android/**/GeneratedPluginRegistrant.java"]);
assert!(
matcher
.buckets
.glob_path_literal_basename
.contains_key(b"GeneratedPluginRegistrant.java".as_slice())
);
assert!(matcher.is_ignored(
b"packages/app/android/src/GeneratedPluginRegistrant.java",
false
));
assert!(matcher.is_ignored(
b"android/app/src/main/java/io/flutter/GeneratedPluginRegistrant.java",
false
));
assert!(!matcher.is_ignored(b"android/app/src/main/java/io/flutter/Other.java", false));
let matcher = ignore_matcher(&[b"**/ios/**/Pods/"]);
assert!(
matcher
.buckets
.glob_directory_literal_basename
.contains_key(b"Pods".as_slice())
);
assert!(matcher.is_ignored(b"ios/Runner/Pods", true));
assert!(matcher.is_ignored(b"dev/app/ios/Runner/Pods/Manifest.lock", false));
assert!(!matcher.is_ignored(b"dev/app/ios/Runner/Podfile", false));
let matcher = ignore_matcher(&[b"**/ios/**/*.mode1v3"]);
assert!(
!matcher.buckets.glob_path_suffix_basename.is_empty(),
"suffix-final slash glob should be prefiltered by basename suffix"
);
assert!(matcher.is_ignored(b"apps/ios/Runner/default.mode1v3", false));
assert!(!matcher.is_ignored(b"apps/ios/Runner/default.mode2v3", false));
let matcher = ignore_matcher(&[b"**/ios/Runner/GeneratedPluginRegistrant.*"]);
assert!(
!matcher.buckets.glob_path_prefix_basename.is_empty(),
"prefix-final slash glob should be prefiltered by basename prefix"
);
assert!(matcher.is_ignored(b"apps/ios/Runner/GeneratedPluginRegistrant.swift", false));
assert!(!matcher.is_ignored(
b"apps/ios/Runner/OtherGeneratedPluginRegistrant.swift",
false
));
let matcher = ignore_matcher(&[b"ios/Scenarios/*.framework/"]);
assert!(
!matcher.buckets.glob_directory_suffix_basename.is_empty(),
"directory suffix-final slash glob should be prefiltered by directory component"
);
assert!(matcher.is_ignored(b"ios/Scenarios/App.framework", true));
assert!(matcher.is_ignored(b"ios/Scenarios/App.framework/Info.plist", false));
assert!(!matcher.is_ignored(b"ios/Scenarios/App.xcframework/Info.plist", false));
}
#[test]
fn ignore_complex_globs_still_use_the_engine() {
let matcher = ignore_matcher(&[b"*.[Cc]ache"]);
assert!(matcher.is_ignored(b"x.cache", false));
assert!(matcher.is_ignored(b"x.Cache", false));
assert!(!matcher.is_ignored(b"x.xache", false));
assert!(matches!(
classify_ignore_pattern(b"*.[Cc]ache"),
MatchKind::Glob
));
let matcher = ignore_matcher(&[b"Icon?"]);
assert!(matcher.is_ignored(b"IconA", false));
assert!(!matcher.is_ignored(b"Icon", false));
assert!(!matcher.is_ignored(b"IconAB", false));
assert!(matches!(
classify_ignore_pattern(b"app.*.symbols"),
MatchKind::Glob
));
assert!(matches!(classify_ignore_pattern(b"a*b*c"), MatchKind::Glob));
let matcher = ignore_matcher(&[b".vscode/*", b"dev/devicelab/ABresults*.json"]);
assert!(matcher.is_ignored(b".vscode/settings.json", false));
assert!(!matcher.is_ignored(b"pkg/.vscode/settings.json", false));
assert!(matcher.is_ignored(b"dev/devicelab/ABresults-1.json", false));
assert!(!matcher.is_ignored(b"dev/devicelab/results-1.json", false));
}
#[test]
fn ignore_negation_still_applies_after_fast_paths() {
let matcher = ignore_matcher(&[b"*.log", b"!keep.log"]);
assert!(matcher.is_ignored(b"a/x.log", false));
assert!(!matcher.is_ignored(b"a/keep.log", false));
}
#[test]
fn read_expected_object_missing_blob_exposes_oid_and_kind() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
let missing = ObjectId::empty_blob(ObjectFormat::Sha1);
let err = read_expected_object(&db, &missing, ObjectType::Blob)
.expect_err("missing blob should error");
let kind = err.not_found_kind().expect("typed not found");
assert_eq!(kind.object_id(), Some(missing));
assert_eq!(kind.missing_object_kind(), Some(MissingObjectKind::Blob));
assert_eq!(
kind.missing_object_context(),
Some(MissingObjectContext::WorktreeMaterialize)
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn update_index_adds_file_entry_and_blob() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
let result = add_paths_to_index(
&root,
&git_dir,
ObjectFormat::Sha1,
&[PathBuf::from("hello.txt")],
)
.expect("test operation should succeed");
assert_eq!(result.entries, 1);
let index = Index::parse_v2_sha1(
&fs::read(repository_index_path(git_dir)).expect("test operation should succeed"),
)
.expect("test operation should succeed");
assert_eq!(index.entries[0].path, b"hello.txt");
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn update_index_and_write_tree_support_sha256() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
let result = add_paths_to_index(
&root,
&git_dir,
ObjectFormat::Sha256,
&[PathBuf::from("hello.txt")],
)
.expect("test operation should succeed");
assert_eq!(result.entries, 1);
let index = Index::parse(
&fs::read(repository_index_path(&git_dir)).expect("test operation should succeed"),
ObjectFormat::Sha256,
)
.expect("test operation should succeed");
assert_eq!(index.entries[0].path, b"hello.txt");
assert_eq!(index.entries[0].oid.format(), ObjectFormat::Sha256);
let tree_oid = write_tree_from_index(&git_dir, ObjectFormat::Sha256)
.expect("test operation should succeed");
assert_eq!(tree_oid.format(), ObjectFormat::Sha256);
let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha256);
let tree = odb
.read_object(&tree_oid)
.expect("test operation should succeed");
assert_eq!(tree.object_type, ObjectType::Tree);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn write_tree_from_index_writes_nested_tree_objects() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("src")).expect("test operation should succeed");
fs::write(root.join("README.md"), b"readme\n").expect("test operation should succeed");
fs::write(root.join("src").join("lib.rs"), b"pub fn demo() {}\n")
.expect("test operation should succeed");
let result = add_paths_to_index(
&root,
&git_dir,
ObjectFormat::Sha1,
&[PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
)
.expect("test operation should succeed");
assert_eq!(result.entries, 2);
let tree_oid = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
let tree = odb
.read_object(&tree_oid)
.expect("test operation should succeed");
assert_eq!(tree.object_type, ObjectType::Tree);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn write_tree_from_index_expands_empty_primary_split_index() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
add_paths_to_index(&root, &git_dir, ObjectFormat::Sha1, &[PathBuf::from("f.txt")])
.expect("test operation should succeed");
let expected = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
enable_split_index(&git_dir, ObjectFormat::Sha1).expect("test operation should succeed");
let primary = read_index(&git_dir);
assert!(
primary.entries.is_empty(),
"fixture should put all entries in the shared index"
);
assert!(
primary
.split_index_link(ObjectFormat::Sha1)
.expect("test operation should succeed")
.is_some(),
"fixture should write a split-index link extension"
);
let actual = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert_eq!(actual, expected);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn short_status_reports_added_and_untracked_paths() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
fs::write(root.join("extra.txt"), b"extra\n").expect("test operation should succeed");
add_paths_to_index(
&root,
&git_dir,
ObjectFormat::Sha1,
&[PathBuf::from("hello.txt")],
)
.expect("test operation should succeed");
let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert_eq!(
status
.iter()
.map(ShortStatusEntry::line)
.collect::<Vec<_>>(),
vec!["A hello.txt", "?? extra.txt"]
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_root_is_none_for_bare_repository() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(&git_dir).expect("create bare git dir");
fs::write(git_dir.join("config"), b"[core]\n\tbare = true\n").expect("write bare config");
assert_eq!(
worktree_root_for_git_dir(&git_dir).expect("resolve bare worktree root"),
None,
"a bare repository has no working tree"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_root_is_parent_for_non_bare_dot_git() {
let root = temp_root();
let work = root.join("work");
let git_dir = work.join(".git");
fs::create_dir_all(&git_dir).expect("create non-bare git dir");
fs::write(git_dir.join("config"), b"[core]\n\tbare = false\n")
.expect("write non-bare config");
assert_eq!(
worktree_root_for_git_dir(&git_dir).expect("resolve non-bare worktree root"),
Some(work.clone()),
"a non-bare .git dir resolves to its parent"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
fn temp_root() -> PathBuf {
let path = std::env::temp_dir().join(format!(
"sley-worktree-{}-{}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).expect("test operation should succeed");
path
}
fn index_entry_for<'a>(index: &'a Index, path: &[u8]) -> &'a IndexEntry {
index
.entries
.iter()
.find(|entry| entry.path == path)
.unwrap_or_else(|| panic!("missing index entry for {}", String::from_utf8_lossy(path)))
}
fn read_index(git_dir: &Path) -> Index {
Index::parse(
&fs::read(repository_index_path(git_dir)).expect("test operation should succeed"),
ObjectFormat::Sha1,
)
.expect("test operation should succeed")
}
fn build_commit(root: &Path, git_dir: &Path, paths: &[&str]) -> ObjectId {
let path_bufs = paths.iter().map(PathBuf::from).collect::<Vec<_>>();
add_paths_to_index(root, git_dir, ObjectFormat::Sha1, &path_bufs)
.expect("test operation should succeed");
let tree = write_tree_from_index(git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
let mut body = Vec::new();
body.extend_from_slice(format!("tree {tree}\n").as_bytes());
body.extend_from_slice(b"author Test <test@example.com> 0 +0000\n");
body.extend_from_slice(b"committer Test <test@example.com> 0 +0000\n");
body.extend_from_slice(b"\n");
body.extend_from_slice(b"sparse fixture\n");
let odb = FileObjectDatabase::from_git_dir(git_dir, ObjectFormat::Sha1);
let commit = odb
.write_object(EncodedObject::new(ObjectType::Commit, body))
.expect("test operation should succeed");
let refs = FileRefStore::new(git_dir, ObjectFormat::Sha1);
let mut tx = refs.transaction();
tx.update(RefUpdate {
name: "refs/heads/main".into(),
expected: None,
new: RefTarget::Direct(commit),
reflog: None,
});
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Symbolic("refs/heads/main".into()),
reflog: None,
});
tx.commit().expect("test operation should succeed");
commit
}
fn full_sparse(patterns: &[&[u8]]) -> SparseCheckout {
SparseCheckout {
patterns: patterns.iter().map(|pattern| pattern.to_vec()).collect(),
sparse_index: false,
}
}
#[test]
fn apply_sparse_checkout_full_mode_skips_out_of_cone_paths() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("in")).expect("test operation should succeed");
fs::create_dir_all(root.join("out")).expect("test operation should succeed");
fs::write(root.join("in").join("keep.txt"), b"keep\n")
.expect("test operation should succeed");
fs::write(root.join("out").join("drop.txt"), b"drop\n")
.expect("test operation should succeed");
fs::write(root.join("top.txt"), b"top\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["in/keep.txt", "out/drop.txt", "top.txt"]);
let sparse = full_sparse(&[b"/in/"]);
let result = apply_sparse_checkout_with_mode(
&root,
&git_dir,
ObjectFormat::Sha1,
&sparse,
SparseCheckoutMode::Full,
)
.expect("test operation should succeed");
assert!(root.join("in").join("keep.txt").exists());
assert!(!root.join("out").join("drop.txt").exists());
assert!(!root.join("top.txt").exists());
assert!(result.materialized.contains(&b"in/keep.txt".to_vec()));
assert!(result.skipped.contains(&b"out/drop.txt".to_vec()));
assert!(result.skipped.contains(&b"top.txt".to_vec()));
let index = read_index(&git_dir);
assert!(!index_entry_skip_worktree(index_entry_for(
&index,
b"in/keep.txt"
)));
assert!(index_entry_skip_worktree(index_entry_for(
&index,
b"out/drop.txt"
)));
assert!(index_entry_skip_worktree(index_entry_for(
&index, b"top.txt"
)));
assert_eq!(index.entries.len(), 3);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn apply_sparse_checkout_toggle_rematerializes() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("a")).expect("test operation should succeed");
fs::create_dir_all(root.join("b")).expect("test operation should succeed");
fs::write(root.join("a").join("file.txt"), b"a\n").expect("test operation should succeed");
fs::write(root.join("b").join("file.txt"), b"b\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["a/file.txt", "b/file.txt"]);
apply_sparse_checkout_with_mode(
&root,
&git_dir,
ObjectFormat::Sha1,
&full_sparse(&[b"/a/"]),
SparseCheckoutMode::Full,
)
.expect("test operation should succeed");
assert!(root.join("a").join("file.txt").exists());
assert!(!root.join("b").join("file.txt").exists());
let index = read_index(&git_dir);
assert!(index_entry_skip_worktree(index_entry_for(
&index,
b"b/file.txt"
)));
apply_sparse_checkout_with_mode(
&root,
&git_dir,
ObjectFormat::Sha1,
&full_sparse(&[b"/b/"]),
SparseCheckoutMode::Full,
)
.expect("test operation should succeed");
assert!(!root.join("a").join("file.txt").exists());
assert!(root.join("b").join("file.txt").exists());
assert_eq!(
fs::read(root.join("b").join("file.txt")).expect("test operation should succeed"),
b"b\n"
);
let index = read_index(&git_dir);
assert!(index_entry_skip_worktree(index_entry_for(
&index,
b"a/file.txt"
)));
assert!(!index_entry_skip_worktree(index_entry_for(
&index,
b"b/file.txt"
)));
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn apply_sparse_checkout_cone_mode_matches_directory_prefixes() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("kept").join("nested"))
.expect("test operation should succeed");
fs::create_dir_all(root.join("other")).expect("test operation should succeed");
fs::write(root.join("kept").join("a.txt"), b"a\n").expect("test operation should succeed");
fs::write(root.join("kept").join("nested").join("b.txt"), b"b\n")
.expect("test operation should succeed");
fs::write(root.join("other").join("c.txt"), b"c\n").expect("test operation should succeed");
fs::write(root.join("root.txt"), b"r\n").expect("test operation should succeed");
build_commit(
&root,
&git_dir,
&["kept/a.txt", "kept/nested/b.txt", "other/c.txt", "root.txt"],
);
let sparse = SparseCheckout {
patterns: vec![b"/*".to_vec(), b"!/*/".to_vec(), b"/kept/".to_vec()],
sparse_index: false,
};
assert!(patterns_are_cone(&sparse.patterns));
apply_sparse_checkout(&root, &git_dir, ObjectFormat::Sha1, &sparse)
.expect("test operation should succeed");
assert!(root.join("root.txt").exists());
assert!(root.join("kept").join("a.txt").exists());
assert!(root.join("kept").join("nested").join("b.txt").exists());
assert!(!root.join("other").join("c.txt").exists());
let index = read_index(&git_dir);
assert!(!index_entry_skip_worktree(index_entry_for(
&index,
b"root.txt"
)));
assert!(!index_entry_skip_worktree(index_entry_for(
&index,
b"kept/a.txt"
)));
assert!(!index_entry_skip_worktree(index_entry_for(
&index,
b"kept/nested/b.txt"
)));
assert!(index_entry_skip_worktree(index_entry_for(
&index,
b"other/c.txt"
)));
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn apply_sparse_checkout_cone_parent_guards_keep_only_direct_files() {
let sparse = SparseCheckout {
patterns: vec![
b"/*".to_vec(),
b"!/*/".to_vec(),
b"/deep/".to_vec(),
b"!/deep/*/".to_vec(),
b"/deep/kept/".to_vec(),
],
sparse_index: false,
};
assert!(path_in_sparse_checkout(
b"deep/file.txt",
&sparse,
SparseCheckoutMode::Cone
));
assert!(path_in_sparse_checkout(
b"deep/kept/file.txt",
&sparse,
SparseCheckoutMode::Cone
));
assert!(!path_in_sparse_checkout(
b"deep/dropped/file.txt",
&sparse,
SparseCheckoutMode::Cone
));
}
#[test]
fn apply_sparse_checkout_honors_preexisting_skip_worktree_via_idempotence() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("in")).expect("test operation should succeed");
fs::create_dir_all(root.join("out")).expect("test operation should succeed");
fs::write(root.join("in").join("keep.txt"), b"keep\n")
.expect("test operation should succeed");
fs::write(root.join("out").join("drop.txt"), b"drop\n")
.expect("test operation should succeed");
build_commit(&root, &git_dir, &["in/keep.txt", "out/drop.txt"]);
let sparse = full_sparse(&[b"/in/"]);
apply_sparse_checkout_with_mode(
&root,
&git_dir,
ObjectFormat::Sha1,
&sparse,
SparseCheckoutMode::Full,
)
.expect("test operation should succeed");
assert!(!root.join("out").join("drop.txt").exists());
let result = apply_sparse_checkout_with_mode(
&root,
&git_dir,
ObjectFormat::Sha1,
&sparse,
SparseCheckoutMode::Full,
)
.expect("test operation should succeed");
assert!(!root.join("out").join("drop.txt").exists());
assert!(root.join("in").join("keep.txt").exists());
assert!(result.skipped.contains(&b"out/drop.txt".to_vec()));
let index = read_index(&git_dir);
assert!(index_entry_skip_worktree(index_entry_for(
&index,
b"out/drop.txt"
)));
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn checkout_detached_sparse_only_writes_in_cone_paths() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("keep")).expect("test operation should succeed");
fs::create_dir_all(root.join("skip")).expect("test operation should succeed");
fs::write(root.join("keep").join("a.txt"), b"a\n").expect("test operation should succeed");
fs::write(root.join("skip").join("b.txt"), b"b\n").expect("test operation should succeed");
let commit = build_commit(&root, &git_dir, &["keep/a.txt", "skip/b.txt"]);
let sparse = full_sparse(&[b"/keep/"]);
let result = checkout_detached_sparse(
&root,
&git_dir,
ObjectFormat::Sha1,
&commit,
b"Test <test@example.com> 0 +0000".to_vec(),
b"checkout".to_vec(),
&sparse,
)
.expect("test operation should succeed");
assert_eq!(result.files, 2);
assert!(root.join("keep").join("a.txt").exists());
assert_eq!(
fs::read(root.join("keep").join("a.txt")).expect("test operation should succeed"),
b"a\n"
);
assert!(!root.join("skip").join("b.txt").exists());
let index = read_index(&git_dir);
assert_eq!(index.entries.len(), 2);
assert!(!index_entry_skip_worktree(index_entry_for(
&index,
b"keep/a.txt"
)));
let skipped = index_entry_for(&index, b"skip/b.txt");
assert!(index_entry_skip_worktree(skipped));
assert_eq!(skipped.mode, 0o100644);
fs::remove_dir_all(root).expect("test operation should succeed");
}
fn config_from(text: &str) -> GitConfig {
GitConfig::parse(text.as_bytes()).expect("test operation should succeed")
}
#[test]
fn smudge_output_eol_decision_table() {
const LF: &[u8] = b"a\nb\nc\n";
const CRLF_MIX_LF: &[u8] = b"a\r\nb\nc\r\n";
const LF_MIX_CR: &[u8] = b"a\nb\rc\n";
let smudge = |cfg: &str, attrline: Option<&[u8]>, input: &[u8]| -> Vec<u8> {
let config = config_from(cfg);
let checks = match attrline {
Some(line) => {
let mut matcher = AttributeMatcher::default();
read_attribute_patterns_from_bytes(line, &mut matcher, &[], b".gitattributes");
matcher.attributes_for_path(b"f.txt", &filter_attribute_names(), false)
}
None => Vec::new(),
};
apply_smudge_filter_with_attributes(&config, &checks, b"f.txt", input)
.expect("smudge must succeed")
};
let attr_text_crlf: &[u8] = b"*.txt text eol=crlf";
for cfg in [
"[core]\n\tautocrlf = false\n\teol = lf\n",
"[core]\n\tautocrlf = false\n\teol = crlf\n",
"[core]\n\tautocrlf = true\n\teol = lf\n",
"[core]\n\tautocrlf = input\n",
] {
assert_eq!(
smudge(cfg, Some(attr_text_crlf), LF),
b"a\r\nb\r\nc\r\n",
"text eol=crlf must add CR to naked LF (cfg={cfg:?})"
);
assert_eq!(
smudge(cfg, Some(attr_text_crlf), CRLF_MIX_LF),
b"a\r\nb\r\nc\r\n",
"text eol=crlf must convert mixed content fully (cfg={cfg:?})"
);
assert_eq!(
smudge(cfg, Some(attr_text_crlf), LF_MIX_CR),
b"a\r\nb\rc\r\n",
"text eol=crlf keeps the lone CR but adds CR to naked LF (cfg={cfg:?})"
);
}
assert_eq!(
smudge(
"[core]\n\tautocrlf = true\n\teol = lf\n",
Some(b"*.txt text"),
LF
),
b"a\r\nb\r\nc\r\n",
"autocrlf=true must override core.eol=lf for plain text attr"
);
assert_eq!(
smudge("[core]\n\teol = crlf\n", Some(b"*.txt text"), LF),
b"a\r\nb\r\nc\r\n",
"core.eol=crlf adds CR to naked LF for plain text attr"
);
assert_eq!(
smudge("[core]\n\teol = lf\n", Some(b"*.txt text"), LF),
LF,
"core.eol=lf leaves naked LF untouched on smudge"
);
assert_eq!(
smudge("[core]\n\tautocrlf = input\n", Some(b"*.txt text"), LF),
LF,
"autocrlf=input overrides core.eol; no CR on smudge"
);
assert_eq!(
smudge("[core]\n\tautocrlf = true\n", Some(b"*.txt text=auto"), LF),
b"a\r\nb\r\nc\r\n",
"text=auto converts a clean naked-LF file"
);
assert_eq!(
smudge(
"[core]\n\tautocrlf = true\n",
Some(b"*.txt text=auto"),
CRLF_MIX_LF
),
CRLF_MIX_LF,
"text=auto must not touch content that already has CRLF"
);
assert_eq!(
smudge(
"[core]\n\tautocrlf = true\n",
Some(b"*.txt text=auto"),
LF_MIX_CR
),
LF_MIX_CR,
"text=auto must not touch content that already has a lone CR"
);
assert_eq!(
smudge("[core]\n\tautocrlf = true\n\teol = lf\n", None, LF),
b"a\r\nb\r\nc\r\n",
"autocrlf=true (no attr) converts clean naked-LF and overrides core.eol=lf"
);
assert_eq!(
smudge("[core]\n\teol = crlf\n", None, LF),
LF,
"no attr + autocrlf=false leaves content untouched even with core.eol=crlf"
);
assert_eq!(
smudge("[core]\n\tautocrlf = true\n", Some(b"*.txt -text"), LF),
LF,
"-text is binary: never convert"
);
}
fn attrs(root: &Path, path: &[u8]) -> Vec<AttributeCheck> {
filter_attribute_checks(root, path).expect("test operation should succeed")
}
#[test]
fn standard_attribute_matcher_matches_per_path_lookup() {
let root = temp_root();
fs::create_dir_all(root.join(".git").join("info")).expect("test operation should succeed");
fs::create_dir_all(root.join("src").join("nested")).expect("test operation should succeed");
fs::write(root.join(".gitattributes"), b"*.rs diff=rust\n")
.expect("test operation should succeed");
fs::write(
root.join("src").join(".gitattributes"),
b"*.rs diff=python\n",
)
.expect("test operation should succeed");
fs::write(
root.join(".git").join("info").join("attributes"),
b"src/nested/*.rs diff=java\n",
)
.expect("test operation should succeed");
let requested = vec![b"diff".to_vec()];
let path = b"src/nested/file.rs";
let per_path = standard_attributes_for_path(&root, path, &requested, false)
.expect("test operation should succeed");
let matcher = StandardAttributeMatcher::from_worktree_root(&root)
.expect("test operation should succeed");
assert_eq!(
matcher.attributes_for_path(path, &requested, false),
per_path
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn filter_attribute_lookup_reads_only_path_chain() {
let root = temp_root();
fs::create_dir_all(root.join(".git").join("info")).expect("test operation should succeed");
fs::create_dir_all(root.join("src").join("nested")).expect("test operation should succeed");
fs::create_dir_all(root.join("sibling")).expect("test operation should succeed");
fs::write(root.join(".gitattributes"), b"*.txt text\n")
.expect("test operation should succeed");
fs::write(root.join("src").join(".gitattributes"), b"*.txt -text\n")
.expect("test operation should succeed");
fs::write(
root.join("sibling").join(".gitattributes"),
b"*.txt eol=crlf\n",
)
.expect("test operation should succeed");
fs::write(
root.join(".git").join("info").join("attributes"),
b"src/nested/*.txt eol=lf\n",
)
.expect("test operation should succeed");
let path = b"src/nested/file.txt";
let full = standard_attributes_for_path(&root, path, &filter_attribute_names(), false)
.expect("test operation should succeed");
assert_eq!(filter_attribute_checks(&root, path).unwrap(), full);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn crlf_to_lf_collapses_only_pairs() {
assert_eq!(
convert_crlf_to_lf_cow(Cow::Borrowed(b"a\r\nb\r\n")).as_ref(),
b"a\nb\n"
);
assert_eq!(
convert_crlf_to_lf_cow(Cow::Borrowed(b"a\rb")).as_ref(),
b"a\rb"
);
assert!(matches!(
convert_crlf_to_lf_cow(Cow::Borrowed(b"a\nb\n")),
Cow::Borrowed(_)
));
}
#[test]
fn lf_to_crlf_does_not_double_convert() {
assert_eq!(convert_lf_to_crlf(b"a\nb\n"), b"a\r\nb\r\n");
assert_eq!(convert_lf_to_crlf(b"a\r\nb\r\n"), b"a\r\nb\r\n");
}
#[test]
fn autocrlf_round_trip_clean_then_smudge() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let worktree = b"line1\r\nline2\r\n";
let blob = apply_clean_filter_with_attributes(&config, &checks, b"file.txt", worktree)
.expect("test operation should succeed");
assert_eq!(blob, b"line1\nline2\n", "clean must normalize CRLF to LF");
let restored = apply_smudge_filter_with_attributes(&config, &checks, b"file.txt", &blob)
.expect("test operation should succeed");
assert_eq!(
restored, worktree,
"smudge must restore CRLF from the LF blob"
);
}
#[test]
fn conv_flags_from_config_matches_git_defaults() {
assert_eq!(ConvFlags::from_config(&config_from("")), ConvFlags::Warn);
assert_eq!(
ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = warn\n")),
ConvFlags::Warn
);
assert_eq!(
ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = WARN\n")),
ConvFlags::Warn
);
assert_eq!(
ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = true\n")),
ConvFlags::Die
);
assert_eq!(
ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = false\n")),
ConvFlags::Off
);
}
#[test]
fn safecrlf_warn_does_not_change_clean_bytes() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let worktree = b"a\nb\nc\n";
let plain = apply_clean_filter_with_attributes(&config, &checks, b"f.txt", worktree)
.expect("clean");
let warned = apply_clean_filter_with_attributes_cow_safecrlf(
&config,
&checks,
b"f.txt",
worktree,
ConvFlags::Warn,
SafeCrlfIndexBlob::None,
)
.expect("clean with safecrlf")
.into_owned();
assert_eq!(plain, warned, "safecrlf must not alter the cleaned bytes");
}
#[test]
fn safecrlf_die_errors_on_lf_to_crlf_round_trip() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let err = apply_clean_filter_with_attributes_cow_safecrlf(
&config,
&checks,
b"f.txt",
b"a\nb\n",
ConvFlags::Die,
SafeCrlfIndexBlob::None,
)
.expect_err("die must error");
assert!(matches!(err, GitError::Exit(128)));
}
#[test]
fn safecrlf_die_errors_on_crlf_to_lf_round_trip() {
let config = config_from("[core]\n\tautocrlf = input\n");
let checks: Vec<AttributeCheck> = Vec::new();
let err = apply_clean_filter_with_attributes_cow_safecrlf(
&config,
&checks,
b"f.txt",
b"a\r\nb\r\n",
ConvFlags::Die,
SafeCrlfIndexBlob::None,
)
.expect_err("die must error");
assert!(matches!(err, GitError::Exit(128)));
}
#[test]
fn safecrlf_reversible_round_trip_does_not_warn_or_die() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let out = apply_clean_filter_with_attributes_cow_safecrlf(
&config,
&checks,
b"f.txt",
b"a\r\nb\r\n",
ConvFlags::Die,
SafeCrlfIndexBlob::None,
)
.expect("reversible round trip must not die");
assert_eq!(out.as_ref(), b"a\nb\n");
}
#[test]
fn safecrlf_binary_content_is_silent() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let body: &[u8] = b"a\nb\0c\n";
let out = apply_clean_filter_with_attributes_cow_safecrlf(
&config,
&checks,
b"f.bin",
body,
ConvFlags::Die,
SafeCrlfIndexBlob::None,
)
.expect("binary content must not die");
assert_eq!(out.as_ref(), body, "binary content is never converted");
}
#[test]
fn safecrlf_off_is_silent_even_on_irreversible_round_trip() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let out = apply_clean_filter_with_attributes_cow_safecrlf(
&config,
&checks,
b"f.txt",
b"a\nb\n",
ConvFlags::Off,
SafeCrlfIndexBlob::None,
)
.expect("safecrlf=off never errors");
assert_eq!(out.as_ref(), b"a\nb\n");
}
#[test]
fn autocrlf_input_normalizes_on_clean_but_not_smudge() {
let config = config_from("[core]\n\tautocrlf = input\n");
let checks: Vec<AttributeCheck> = Vec::new();
let blob = apply_clean_filter_with_attributes(&config, &checks, b"file.txt", b"a\r\nb\r\n")
.expect("test operation should succeed");
assert_eq!(blob, b"a\nb\n");
let smudged = apply_smudge_filter_with_attributes(&config, &checks, b"file.txt", &blob)
.expect("test operation should succeed");
assert_eq!(
smudged, b"a\nb\n",
"input mode must not add carriage returns"
);
}
#[test]
fn eol_crlf_attribute_drives_conversion_without_config() {
let config = config_from("");
let checks = vec![AttributeCheck {
attribute: b"eol".to_vec(),
state: Some(AttributeState::Value(b"crlf".to_vec())),
}];
let blob = apply_clean_filter_with_attributes(&config, &checks, b"a.txt", b"x\r\ny\r\n")
.expect("test operation should succeed");
assert_eq!(blob, b"x\ny\n");
let smudged = apply_smudge_filter_with_attributes(&config, &checks, b"a.txt", &blob)
.expect("test operation should succeed");
assert_eq!(smudged, b"x\r\ny\r\n");
}
#[test]
fn binary_attribute_disables_eol_conversion() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks = vec![AttributeCheck {
attribute: b"text".to_vec(),
state: Some(AttributeState::Unset),
}];
let content = b"\x00\x01\r\n\x02\r\n".to_vec();
let blob = apply_clean_filter_with_attributes(&config, &checks, b"data.bin", &content)
.expect("test operation should succeed");
assert_eq!(blob, content, "binary file must not be CRLF-normalized");
let smudged = apply_smudge_filter_with_attributes(&config, &checks, b"data.bin", &blob)
.expect("test operation should succeed");
assert_eq!(
smudged, content,
"binary file must not gain carriage returns"
);
}
#[test]
fn autocrlf_auto_skips_binary_looking_content() {
let config = config_from("[core]\n\tautocrlf = true\n");
let checks: Vec<AttributeCheck> = Vec::new();
let content = b"a\r\n\x00b\r\n".to_vec();
let blob = apply_clean_filter_with_attributes(&config, &checks, b"f", &content)
.expect("test operation should succeed");
assert_eq!(blob, content, "binary-looking content stays untouched");
}
#[test]
fn autocrlf_via_add_and_checkout_round_trips() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
let config = config_from("[core]\n\tautocrlf = true\n");
fs::write(root.join("crlf.txt"), b"alpha\r\nbeta\r\n")
.expect("test operation should succeed");
add_paths_to_index_filtered(
&root,
&git_dir,
ObjectFormat::Sha1,
&[PathBuf::from("crlf.txt")],
&config,
)
.expect("test operation should succeed");
let index = read_index(&git_dir);
let entry = index_entry_for(&index, b"crlf.txt");
let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
let blob = odb
.read_object(&entry.oid)
.expect("test operation should succeed");
assert_eq!(blob.body, b"alpha\nbeta\n");
let tree = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
let mut body = Vec::new();
body.extend_from_slice(format!("tree {tree}\n").as_bytes());
body.extend_from_slice(b"author T <t@e> 0 +0000\ncommitter T <t@e> 0 +0000\n\nm\n");
let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
let commit = odb
.write_object(EncodedObject::new(ObjectType::Commit, body))
.expect("test operation should succeed");
let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
let mut tx = refs.transaction();
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Direct(commit),
reflog: None,
});
tx.commit().expect("test operation should succeed");
fs::write(root.join("crlf.txt"), b"alpha\nbeta\n").expect("test operation should succeed");
checkout_detached_filtered(
&root,
&git_dir,
ObjectFormat::Sha1,
&commit,
b"T <t@e> 0 +0000".to_vec(),
b"co".to_vec(),
&config,
)
.expect("test operation should succeed");
assert_eq!(
fs::read(root.join("crlf.txt")).expect("test operation should succeed"),
b"alpha\r\nbeta\r\n",
"checkout must restore CRLF line endings"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn driver_filter_clean_and_smudge_transform_both_directions() {
let config =
config_from("[filter \"case\"]\n\tclean = tr a-z A-Z\n\tsmudge = tr A-Z a-z\n");
let checks = vec![AttributeCheck {
attribute: b"filter".to_vec(),
state: Some(AttributeState::Value(b"case".to_vec())),
}];
let blob = apply_clean_filter_with_attributes(&config, &checks, b"f.txt", b"Hello World")
.expect("test operation should succeed");
assert_eq!(blob, b"HELLO WORLD", "clean driver must upper-case");
let worktree =
apply_smudge_filter_with_attributes(&config, &checks, b"f.txt", b"HELLO WORLD")
.expect("test operation should succeed");
assert_eq!(worktree, b"hello world", "smudge driver must lower-case");
}
#[test]
fn driver_filter_resolved_from_gitattributes_file() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join(".gitattributes"), b"*.dat filter=rot\n")
.expect("test operation should succeed");
let config =
config_from("[filter \"rot\"]\n\tclean = sed s/a/b/g\n\tsmudge = sed s/b/a/g\n");
let blob = apply_clean_filter(&root, &git_dir, &config, b"x.dat", b"banana")
.expect("test operation should succeed");
assert_eq!(blob, b"bbnbnb");
add_paths_to_index(
&root,
&git_dir,
ObjectFormat::Sha1,
&[PathBuf::from(".gitattributes")],
)
.expect("test operation should succeed");
let smudged = apply_smudge_filter(
&root,
&git_dir,
ObjectFormat::Sha1,
&config,
b"x.dat",
&blob,
)
.expect("test operation should succeed");
assert_eq!(smudged, b"aanana");
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn required_filter_failure_is_fatal() {
let config = config_from("[filter \"boom\"]\n\tclean = false\n\trequired = true\n");
let checks = vec![AttributeCheck {
attribute: b"filter".to_vec(),
state: Some(AttributeState::Value(b"boom".to_vec())),
}];
let err = apply_clean_filter_with_attributes(&config, &checks, b"f", b"data")
.expect_err("required filter failure must error");
assert!(matches!(err, GitError::Command(_)), "got {err:?}");
}
#[test]
fn required_filter_missing_command_is_fatal() {
let config = config_from("[filter \"need\"]\n\tsmudge = cat\n\trequired = true\n");
let checks = vec![AttributeCheck {
attribute: b"filter".to_vec(),
state: Some(AttributeState::Value(b"need".to_vec())),
}];
let err = apply_clean_filter_with_attributes(&config, &checks, b"f", b"data")
.expect_err("required filter without a clean command must error");
assert!(matches!(err, GitError::Exit(128)), "got {err:?}");
}
#[test]
fn non_required_filter_failure_passes_through() {
let config = config_from("[filter \"opt\"]\n\tclean = false\n");
let checks = vec![AttributeCheck {
attribute: b"filter".to_vec(),
state: Some(AttributeState::Value(b"opt".to_vec())),
}];
let out = apply_clean_filter_with_attributes(&config, &checks, b"f", b"keepme")
.expect("test operation should succeed");
assert_eq!(
out, b"keepme",
"optional filter failure passes content through"
);
}
#[test]
fn filter_with_no_command_is_noop() {
let config = config_from("");
let checks = vec![AttributeCheck {
attribute: b"filter".to_vec(),
state: Some(AttributeState::Value(b"ghost".to_vec())),
}];
let out = apply_clean_filter_with_attributes(&config, &checks, b"f", b"unchanged")
.expect("test operation should succeed");
assert_eq!(out, b"unchanged");
}
#[test]
fn driver_and_eol_compose_on_clean_and_smudge() {
let config = config_from(
"[core]\n\tautocrlf = true\n[filter \"case\"]\n\tclean = tr a-z A-Z\n\tsmudge = tr A-Z a-z\n",
);
let checks = vec![
AttributeCheck {
attribute: b"filter".to_vec(),
state: Some(AttributeState::Value(b"case".to_vec())),
},
AttributeCheck {
attribute: b"text".to_vec(),
state: Some(AttributeState::Set),
},
];
let blob = apply_clean_filter_with_attributes(&config, &checks, b"f.txt", b"ab\r\ncd\r\n")
.expect("test operation should succeed");
assert_eq!(blob, b"AB\nCD\n", "clean: upper-case then CRLF->LF");
let worktree = apply_smudge_filter_with_attributes(&config, &checks, b"f.txt", &blob)
.expect("test operation should succeed");
assert_eq!(
worktree, b"ab\r\ncd\r\n",
"smudge: LF->CRLF then lower-case"
);
}
#[test]
fn attrs_helper_reads_filter_from_disk() {
let root = temp_root();
fs::write(root.join(".gitattributes"), b"*.txt text\n*.bin -text\n")
.expect("test operation should succeed");
let text = attrs(&root, b"a.txt");
assert!(
text.iter()
.any(|c| c.attribute == b"text" && c.state == Some(AttributeState::Set))
);
let bin = attrs(&root, b"a.bin");
assert!(
bin.iter()
.any(|c| c.attribute == b"text" && c.state == Some(AttributeState::Unset))
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
fn stat_cache_for(file: &Path, oid: ObjectId, mode: u32) -> (IndexStatCache, IndexEntry) {
let metadata = fs::metadata(file).expect("test operation should succeed");
let mut entry = index_entry_from_metadata(b"f.txt".to_vec(), oid, &metadata);
entry.mode = mode;
let index_mtime = Some((u64::from(entry.mtime_seconds) + 10, 0));
let mut entries = HashMap::new();
entries.insert(entry.path.as_bytes().to_vec(), entry.clone());
(
IndexStatCache {
entries,
index_mtime,
},
entry,
)
}
#[test]
fn reuse_tracked_entry_only_reuses_clean_non_racy_match() {
let root = temp_root();
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
let file = root.join("f.txt");
let metadata = fs::metadata(&file).expect("test operation should succeed");
let real_mode = file_mode(&metadata);
let oid = EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec())
.object_id(ObjectFormat::Sha1)
.expect("test operation should succeed");
let (cache, _) = stat_cache_for(&file, oid, real_mode);
let reused = cache.reuse_tracked_entry(b"f.txt", &metadata);
assert_eq!(
reused,
Some(TrackedEntry {
mode: real_mode,
oid,
}),
"a clean non-racy stat+mode match must reuse the staged oid"
);
assert_eq!(
cache.reuse_tracked_entry(b"other.txt", &metadata),
None,
"a path with no cached entry must fall through to hashing"
);
let (mut size_cache, mut shrunk) = stat_cache_for(&file, oid, real_mode);
shrunk.size = shrunk.size.saturating_sub(1);
size_cache.entries.insert(shrunk.path.to_vec(), shrunk);
assert_eq!(
size_cache.reuse_tracked_entry(b"f.txt", &metadata),
None,
"a size mismatch must fall through to hashing"
);
let (mode_cache, _) = stat_cache_for(&file, oid, 0o100755);
assert_eq!(
mode_cache.reuse_tracked_entry(b"f.txt", &metadata),
None,
"a mode mismatch must fall through to hashing"
);
let (mut racy_cache, entry) = stat_cache_for(&file, oid, real_mode);
racy_cache.index_mtime = Some((
u64::from(entry.mtime_seconds),
u64::from(entry.mtime_nanoseconds),
));
assert_eq!(
racy_cache.reuse_tracked_entry(b"f.txt", &metadata),
None,
"a racily-clean entry must always be re-hashed"
);
let (mut unknown_cache, _) = stat_cache_for(
&file,
EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec())
.object_id(ObjectFormat::Sha1)
.expect("test operation should succeed"),
real_mode,
);
unknown_cache.index_mtime = None;
assert_eq!(
unknown_cache.reuse_tracked_entry(b"f.txt", &metadata),
None,
"an unknown index mtime must be treated conservatively as racy"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn index_stat_probe_cache_serves_many_paths_from_one_index_parse() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("a.txt"), b"alpha\n").expect("test operation should succeed");
fs::write(root.join("b.txt"), b"bravo\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["a.txt", "b.txt"]);
let cache = IndexStatProbeCache::from_repository_index(&git_dir, ObjectFormat::Sha1)
.expect("probe cache");
assert_eq!(cache.len(), 2);
assert!(cache.contains_git_path(b"a.txt"));
assert!(cache.contains_git_path(b"b.txt"));
let a = cache.probe_for_git_path(b"a.txt").expect("a probe");
let b = cache.probe_for_git_path(b"b.txt").expect("b probe");
assert_eq!(a.entry().path, b"a.txt");
assert_eq!(b.entry().path, b"b.txt");
assert_eq!(a.index_mtime(), cache.index_mtime());
assert_eq!(b.index_mtime(), cache.index_mtime());
assert!(
cache.probe_for_git_path(b"missing.txt").is_none(),
"missing paths should not allocate probes"
);
let one_shot =
IndexStatProbe::from_repository_index(&git_dir, ObjectFormat::Sha1, b"a.txt")
.expect("legacy one-shot probe")
.expect("a probe");
assert_eq!(one_shot.entry().path, b"a.txt");
assert_eq!(one_shot.index_mtime(), cache.index_mtime());
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn short_status_detects_same_length_content_change() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"aaaa\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
fs::write(root.join("f.txt"), b"bbbb\n").expect("test operation should succeed");
let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert_eq!(
status
.iter()
.map(ShortStatusEntry::line)
.collect::<Vec<_>>(),
vec![" M f.txt"],
"a same-length content change must be reported modified"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn short_status_clean_after_byte_identical_rewrite() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert!(
status.is_empty(),
"a byte-identical rewrite must be clean via the fallback hash, got {status:?}"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn short_status_trusts_stat_cache_and_skips_rehash() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
let index_path = repository_index_path(&git_dir);
let mut index = read_index(&git_dir);
let bogus = ObjectId::from_hex(ObjectFormat::Sha1, &"0".repeat(40))
.expect("test operation should succeed");
let real_oid = index_entry_for(&index, b"f.txt").oid;
assert_ne!(
real_oid, bogus,
"fixture oid should differ from the bogus oid"
);
index
.entries
.iter_mut()
.find(|entry| entry.path == b"f.txt")
.expect("test operation should succeed")
.oid = bogus.clone();
fs::write(
&index_path,
index
.write(ObjectFormat::Sha1)
.expect("test operation should succeed"),
)
.expect("test operation should succeed");
std::thread::sleep(std::time::Duration::from_millis(1100));
fs::write(
&index_path,
fs::read(&index_path).expect("test operation should succeed"),
)
.expect("test operation should succeed");
let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
let entry = status
.iter()
.find(|entry| entry.path == b"f.txt")
.expect("f.txt should appear (its index oid now differs from HEAD)");
assert_eq!(
entry.worktree, b' ',
"non-racy stat match must trust the cached oid (no re-hash); worktree column was {}",
entry.worktree as char
);
assert_eq!(
entry.index_oid.as_ref(),
Some(&bogus),
"the worktree entry must have reused the planted bogus index oid, not the real hash"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_entry_state_detects_same_size_content_change() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"aaaa\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
let index = read_index(&git_dir);
let entry = index_entry_for(&index, b"f.txt").clone();
let probe = IndexStatProbe::from_index_entry_and_index_path(
entry.clone(),
repository_index_path(&git_dir),
);
fs::write(root.join("f.txt"), b"bbbb\n").expect("test operation should succeed");
let state = worktree_entry_state(
&root,
&git_dir,
ObjectFormat::Sha1,
Path::new("f.txt"),
&entry.oid,
entry.mode,
Some(&probe),
)
.expect("test operation should succeed");
assert_eq!(state, WorktreeEntryState::Modified);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_entry_state_reports_deleted_for_missing_and_parent_not_directory() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("dir")).expect("test operation should succeed");
fs::write(root.join("dir").join("f.txt"), b"hello\n")
.expect("test operation should succeed");
build_commit(&root, &git_dir, &["dir/f.txt"]);
let index = read_index(&git_dir);
let entry = index_entry_for(&index, b"dir/f.txt").clone();
fs::remove_file(root.join("dir").join("f.txt")).expect("test operation should succeed");
let missing = worktree_entry_state_by_git_path(
&root,
&git_dir,
ObjectFormat::Sha1,
b"dir/f.txt",
&entry.oid,
entry.mode,
None,
)
.expect("test operation should succeed");
assert_eq!(missing, WorktreeEntryState::Deleted);
fs::remove_dir(root.join("dir")).expect("test operation should succeed");
fs::write(root.join("dir"), b"not a directory").expect("test operation should succeed");
let parent_not_directory = worktree_entry_state_by_git_path(
&root,
&git_dir,
ObjectFormat::Sha1,
b"dir/f.txt",
&entry.oid,
entry.mode,
None,
)
.expect("test operation should succeed");
assert_eq!(parent_not_directory, WorktreeEntryState::Deleted);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_entry_state_trusts_clean_non_racy_probe() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
let index_path = repository_index_path(&git_dir);
let mut index = read_index(&git_dir);
let bogus = ObjectId::from_hex(ObjectFormat::Sha1, &"1".repeat(40))
.expect("test operation should succeed");
index
.entries
.iter_mut()
.find(|entry| entry.path == b"f.txt")
.expect("test operation should succeed")
.oid = bogus;
fs::write(
&index_path,
index
.write(ObjectFormat::Sha1)
.expect("test operation should succeed"),
)
.expect("test operation should succeed");
std::thread::sleep(std::time::Duration::from_millis(1100));
fs::write(
&index_path,
fs::read(&index_path).expect("test operation should succeed"),
)
.expect("test operation should succeed");
let index = read_index(&git_dir);
let entry = index_entry_for(&index, b"f.txt").clone();
let probe = IndexStatProbe::from_index_entry_and_index_path(
entry.clone(),
repository_index_path(&git_dir),
);
let state = worktree_entry_state(
&root,
&git_dir,
ObjectFormat::Sha1,
Path::new("f.txt"),
&entry.oid,
entry.mode,
Some(&probe),
)
.expect("test operation should succeed");
assert_eq!(
state,
WorktreeEntryState::Clean,
"a non-racy stat match must be enough to prove this path clean"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_entry_state_rehashes_racy_probe() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
let index = read_index(&git_dir);
let mut entry = index_entry_for(&index, b"f.txt").clone();
entry.oid = ObjectId::from_hex(ObjectFormat::Sha1, &"2".repeat(40))
.expect("test operation should succeed");
let probe = IndexStatProbe::from_index_entry(
entry.clone(),
Some((
u64::from(entry.mtime_seconds),
u64::from(entry.mtime_nanoseconds),
)),
);
let state = worktree_entry_state(
&root,
&git_dir,
ObjectFormat::Sha1,
Path::new("f.txt"),
&entry.oid,
entry.mode,
Some(&probe),
)
.expect("test operation should succeed");
assert_eq!(
state,
WorktreeEntryState::Modified,
"a racily-clean stat match must fall through to hashing"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[cfg(unix)]
#[test]
fn worktree_entry_state_detects_chmod_only_change() {
use std::os::unix::fs::PermissionsExt;
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
build_commit(&root, &git_dir, &["f.txt"]);
let index = read_index(&git_dir);
let entry = index_entry_for(&index, b"f.txt").clone();
let file = root.join("f.txt");
let mut permissions = fs::metadata(&file)
.expect("test operation should succeed")
.permissions();
permissions.set_mode(permissions.mode() | 0o111);
fs::set_permissions(&file, permissions).expect("test operation should succeed");
let state = worktree_entry_state(
&root,
&git_dir,
ObjectFormat::Sha1,
Path::new("f.txt"),
&entry.oid,
entry.mode,
None,
)
.expect("test operation should succeed");
assert_eq!(state, WorktreeEntryState::Modified);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[cfg(unix)]
#[test]
fn worktree_entry_state_detects_symlink_target_change() {
use std::os::unix::fs::symlink;
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
symlink("one", root.join("link")).expect("test operation should succeed");
build_commit(&root, &git_dir, &["link"]);
let index = read_index(&git_dir);
let entry = index_entry_for(&index, b"link").clone();
fs::remove_file(root.join("link")).expect("test operation should succeed");
symlink("two", root.join("link")).expect("test operation should succeed");
let state = worktree_entry_state(
&root,
&git_dir,
ObjectFormat::Sha1,
Path::new("link"),
&entry.oid,
entry.mode,
None,
)
.expect("test operation should succeed");
assert_eq!(state, WorktreeEntryState::Modified);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn worktree_entry_state_treats_present_unpopulated_gitlink_directory_as_clean() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::create_dir_all(root.join("submodule")).expect("test operation should succeed");
let oid = ObjectId::from_hex(ObjectFormat::Sha1, &"3".repeat(40))
.expect("test operation should succeed");
let state = worktree_entry_state(
&root,
&git_dir,
ObjectFormat::Sha1,
Path::new("submodule"),
&oid,
sley_index::GITLINK_MODE,
None,
)
.expect("test operation should succeed");
assert_eq!(state, WorktreeEntryState::Clean);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn short_status_empty_on_unborn_repository() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")
.expect("test operation should succeed");
let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert!(
status.is_empty(),
"an unborn repository with an empty worktree must be clean, got {status:?}"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[test]
fn untracked_paths_skips_embedded_git_internals() {
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")
.expect("test operation should succeed");
let nested = root.join("not-a-submodule");
fs::create_dir_all(nested.join(".git")).expect("test operation should succeed");
fs::write(nested.join(".git/HEAD"), "ref: refs/heads/main\n")
.expect("test operation should succeed");
fs::write(nested.join("file.txt"), b"inside\n").expect("test operation should succeed");
let paths = untracked_paths(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert!(
paths.iter().any(|path| path == b"not-a-submodule/"),
"embedded repository directory should be listed, got {paths:?}"
);
assert!(
!paths
.iter()
.any(|path| path.starts_with(b"not-a-submodule/.git")),
"embedded .git internals must not be listed, got {paths:?}"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
#[cfg(unix)]
#[test]
fn untracked_paths_lists_symlink() {
use std::os::unix::fs::symlink;
let root = temp_root();
let git_dir = root.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")
.expect("test operation should succeed");
fs::write(root.join("target.txt"), b"target\n").expect("test operation should succeed");
symlink(root.join("target.txt"), root.join("path1")).expect("create symlink");
let paths = untracked_paths(&root, &git_dir, ObjectFormat::Sha1)
.expect("test operation should succeed");
assert!(
paths.contains(&b"path1".to_vec()),
"untracked symlink must be listed, got {paths:?}"
);
fs::remove_dir_all(root).expect("test operation should succeed");
}
}