use crate::repo_read_index::{IndexEntry, parse_index};
use crate::repo_read_layout::ObjectFormat;
use crate::repo_read_odb::ObjectDatabase;
use crate::repo_read_refs::{Refs, RefValue};
use crate::repo_read_tree::{FlatEntry, MODE_GITLINK, flatten_tree, parse_commit};
use crate::fs_beneath::{entry_metadata_at, open_directory_beneath, open_root, plain_components};
use crate::worktree_clean_content::{ContentJudge, ConversionEnvironment, Place};
use crate::subvolume_index::{StatFields, StoredStat, be32, walk_entries};
use crate::subvolume_index_files::{fields_of, matches_stored, repository_hash_length};
use crate::transaction_git::{GitContext, GitRequest, run_git};
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
const IN_PROGRESS: &[&str] =
&["index.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
const ASSUME_VALID: u16 = 0x8000;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CleanRefusalKind {
OperationInProgress(&'static str),
Unreadable,
NotPlain,
StagedChange,
FileChanged,
StatStale,
Racy,
Untracked,
}
impl CleanRefusalKind {
pub fn restorable(self) -> bool {
return matches!(self, Self::StagedChange | Self::FileChanged | Self::StatStale | Self::Racy | Self::Untracked);
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CleanRefusal {
pub kind: CleanRefusalKind,
pub reason: String,
}
impl CleanRefusal {
fn new(kind: CleanRefusalKind, reason: impl Into<String>) -> CleanRefusal {
return CleanRefusal { kind, reason: reason.into() };
}
fn unreadable(reason: impl Into<String>) -> CleanRefusal {
return CleanRefusal::new(CleanRefusalKind::Unreadable, reason);
}
}
impl std::fmt::Display for CleanRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str(self.reason.as_str());
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StatEntry {
pub path: Vec<u8>,
pub flags: u16,
pub mode: u32,
pub stored: StoredStat,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CleanSource {
pub head_commit: String,
pub head_tree: Vec<u8>,
pub tracked: usize,
pub index_seconds: u32,
pub hash_length: usize,
pub attribute_blobs: Vec<(Vec<u8>, Vec<u8>)>,
pub index_bytes: Vec<u8>,
}
fn operation_in_progress(git_dir: &Path, owned_index_lock: bool) -> Option<&'static str> {
return IN_PROGRESS.iter().copied().filter(|name: &&str| { return !(owned_index_lock && *name == "index.lock"); }).find(|name: &&str| { return git_dir.join(name).symlink_metadata().is_ok(); });
}
pub fn head_commit(git_dir: &Path, common_dir: &Path, format: ObjectFormat) -> Option<String> {
let refs: Refs = Refs::new(common_dir, format.hex_length());
return match refs.lookup_head(git_dir)? {
RefValue::Direct(object) => Some(object),
RefValue::Symbolic(name) => refs.resolve(name.as_str()),
};
}
pub fn stored_stats(bytes: &[u8], hash_length: usize) -> Option<Vec<StatEntry>> {
let (entries, _end) = walk_entries(bytes, hash_length)?;
let mut found: Vec<StatEntry> = Vec::with_capacity(entries.len());
for entry in entries {
let stored: StoredStat = StoredStat {
ctime_seconds: be32(bytes, entry.start)?,
ctime_nanos: be32(bytes, entry.start + 4)?,
mtime_seconds: be32(bytes, entry.start + 8)?,
mtime_nanos: be32(bytes, entry.start + 12)?,
ino: be32(bytes, entry.start + 20)?,
uid: be32(bytes, entry.start + 28)?,
gid: be32(bytes, entry.start + 32)?,
size: be32(bytes, entry.start + 36)?,
};
found.push(StatEntry { path: entry.path, flags: entry.flags, mode: entry.mode, stored });
}
return Some(found);
}
fn index_differs_from_tree(index: &[IndexEntry], tree: &[FlatEntry]) -> Option<CleanRefusal> {
if index.len() != tree.len() {
return Some(CleanRefusal::new(CleanRefusalKind::StagedChange, "the index and the tree of HEAD hold different numbers of files"));
}
for (staged, committed) in index.iter().zip(tree) {
if staged.stage != 0 || staged.extended != 0 {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("the index entry {} is unmerged, intent-to-add or skip-worktree", String::from_utf8_lossy(&staged.path))));
}
if staged.mode == MODE_GITLINK {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule", String::from_utf8_lossy(&staged.path))));
}
if staged.path != committed.path || staged.mode != committed.mode || staged.id != committed.id {
return Some(CleanRefusal::new(CleanRefusalKind::StagedChange, format!("{} is staged with a change", String::from_utf8_lossy(&staged.path))));
}
}
return None;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StatVerdict {
Agrees,
Unproven(String),
Differs(String),
}
pub fn metadata_verdict(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> StatVerdict {
return match stat_finding(entry, metadata, index_seconds) {
None => StatVerdict::Agrees,
Some(CleanRefusal { kind: CleanRefusalKind::Racy | CleanRefusalKind::StatStale, reason }) => StatVerdict::Unproven(reason),
Some(refusal) => StatVerdict::Differs(refusal.reason),
};
}
fn stat_finding(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> Option<CleanRefusal> {
let StatEntry { path, flags, mode, stored }: &StatEntry = entry;
let shown: String = String::from_utf8_lossy(path).into_owned();
if flags & ASSUME_VALID != 0 {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{shown} is marked assume-unchanged")));
}
let symbolic: bool = *mode & 0o170_000 == 0o120_000;
if symbolic != metadata.file_type().is_symlink() || (!symbolic && !metadata.file_type().is_file()) {
return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, format!("{shown} changed its type")));
}
if !symbolic && (*mode & 0o100 != 0) != (metadata.mode() & 0o100 != 0) {
return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, format!("{shown} changed its executable bit")));
}
if stored.mtime_seconds >= index_seconds {
return Some(CleanRefusal::new(CleanRefusalKind::Racy, format!("{shown} is as new as the index, so its stat fields prove nothing")));
}
let now: StatFields = fields_of(metadata);
if !matches_stored(&now, stored) {
return Some(CleanRefusal::new(CleanRefusalKind::StatStale, format!("{shown} differs from its index entry by size, times or inode")));
}
return None;
}
pub fn metadata_differs_from_entry(entry: &StatEntry, metadata: &std::fs::Metadata, index_seconds: u32) -> Option<String> {
return match metadata_verdict(entry, metadata, index_seconds) {
StatVerdict::Agrees => None,
StatVerdict::Unproven(reason) | StatVerdict::Differs(reason) => Some(reason),
};
}
pub fn tree_differs_from_index(root: &Path, entries: &[StatEntry], index_seconds: u32) -> Option<String> {
return match open_root(root) {
Ok(held) => tree_differs_from_index_beneath(&held, entries, index_seconds),
Err(error) => Some(format!("{} cannot be opened without following a link ({error})", root.display())),
};
}
fn tree_refusal(root: &Path, entries: &[StatEntry], index_seconds: u32) -> Option<CleanRefusal> {
return match open_root(root) {
Ok(held) => tree_refusal_beneath(&held, entries, index_seconds),
Err(error) => Some(CleanRefusal::unreadable(format!("{} cannot be opened without following a link ({error})", root.display()))),
};
}
pub struct ContentTarget<'a> {
pub root: &'a Path,
pub git_dir: &'a Path,
pub common_dir: &'a Path,
pub environment: &'a ConversionEnvironment,
pub format: ObjectFormat,
}
fn tree_differs_from_index_by_content(target: &ContentTarget<'_>, entries: &[StatEntry], indexed: &[IndexEntry], index_seconds: u32) -> Option<String> {
if entries.len() != indexed.len() {
return Some(String::from("the index entries and their stat data are not the same files"));
}
let held: std::fs::File = match open_root(target.root) {
Ok(opened) => opened,
Err(error) => return Some(format!("{} cannot be opened without following a link ({error})", target.root.display())),
};
let mut judge: ContentJudge<'_> = ContentJudge::new(&held, target.git_dir, target.common_dir, target.environment, target.format);
let mut cached: Option<(Vec<std::ffi::OsString>, std::fs::File)> = None;
for (entry, committed) in entries.iter().zip(indexed) {
if entry.path != committed.path {
return Some(String::from("the index entries and their stat data are not the same files"));
}
let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
let Ok(names) = plain_components(Path::new(OsStr::from_bytes(entry.path.as_slice()))) else { return Some(format!("{shown} is not a plain path")) };
let Some((name, parents)) = names.split_last() else { return Some(format!("{shown} is not a plain path")) };
if cached.as_ref().is_none_or(|(known, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return known.as_slice() != parents; }) {
let Ok(directory) = open_directory_beneath(&held, parents) else { return Some(format!("{shown} is missing from the worktree or reached through a link")) };
cached = Some((parents.to_vec(), directory));
}
let Some((_known, directory)) = cached.as_ref() else { return Some(format!("{shown} is missing from the worktree")) };
let Ok(metadata) = entry_metadata_at(directory, name.as_os_str()) else { return Some(format!("{shown} is missing from the worktree")) };
match metadata_verdict(entry, &metadata, index_seconds) {
StatVerdict::Agrees => {}
StatVerdict::Differs(reason) => return Some(reason),
StatVerdict::Unproven(_reason) => {
let place: Place<'_> = Place { directory, parents, name: name.as_os_str() };
if let Err(reason) = judge.agrees(&place, entry, committed.id.as_slice(), index_seconds) {
return Some(reason);
}
}
}
}
return None;
}
pub fn tree_differs_from_index_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Option<String> {
return tree_refusal_beneath(root, entries, index_seconds).map(|refusal: CleanRefusal| { return refusal.reason; });
}
fn tree_refusal_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Option<CleanRefusal> {
let changed = |reason: String| -> Option<CleanRefusal> { return Some(CleanRefusal::new(CleanRefusalKind::FileChanged, reason)); };
let mut cached: Option<(Vec<std::ffi::OsString>, std::fs::File)> = None;
for entry in entries {
let shown: String = String::from_utf8_lossy(entry.path.as_slice()).into_owned();
let Ok(names) = plain_components(Path::new(OsStr::from_bytes(entry.path.as_slice()))) else { return Some(CleanRefusal::unreadable(format!("{shown} is not a plain path"))) };
let Some((name, parents)) = names.split_last() else { return Some(CleanRefusal::unreadable(format!("{shown} is not a plain path"))) };
if cached.as_ref().is_none_or(|(held, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return held.as_slice() != parents; }) {
let Ok(directory) = open_directory_beneath(root, parents) else { return changed(format!("{shown} is missing from the worktree or reached through a link")) };
cached = Some((parents.to_vec(), directory));
}
let Some((_held, directory)) = cached.as_ref() else { return changed(format!("{shown} is missing from the worktree")) };
let Ok(metadata) = entry_metadata_at(directory, name.as_os_str()) else { return changed(format!("{shown} is missing from the worktree")) };
if let Some(refusal) = stat_finding(entry, &metadata, index_seconds) {
return Some(refusal);
}
}
return None;
}
pub struct StatCursor<'a> {
root: &'a std::fs::File,
held: Option<(Vec<std::ffi::OsString>, std::fs::File)>,
}
impl<'a> StatCursor<'a> {
pub fn new(root: &'a std::fs::File) -> Self {
return Self { root, held: None };
}
fn metadata(&mut self, path: &[u8]) -> Result<std::fs::Metadata, String> {
let shown: String = String::from_utf8_lossy(path).into_owned();
let Ok(names) = plain_components(Path::new(OsStr::from_bytes(path))) else { return Err(format!("{shown} is not a plain path")) };
let Some((name, parents)) = names.split_last() else { return Err(format!("{shown} is not a plain path")) };
if self.held.as_ref().is_none_or(|(known, _directory): &(Vec<std::ffi::OsString>, std::fs::File)| { return known.as_slice() != parents; }) {
let Ok(directory) = open_directory_beneath(self.root, parents) else { return Err(format!("{shown} is missing from the worktree or reached through a link")) };
self.held = Some((parents.to_vec(), directory));
}
let Some((_known, directory)) = self.held.as_ref() else { return Err(format!("{shown} is missing from the worktree")) };
return entry_metadata_at(directory, name.as_os_str()).map_err(|_error| { return format!("{shown} is missing from the worktree"); });
}
pub fn fields(&mut self, path: &[u8]) -> Option<StatFields> {
return self.metadata(path).ok().map(|found: std::fs::Metadata| { return fields_of(&found); });
}
}
pub fn tree_fields_beneath(root: &std::fs::File, entries: &[StatEntry], index_seconds: u32) -> Result<Vec<StatFields>, CleanRefusal> {
let mut cursor: StatCursor<'_> = StatCursor::new(root);
let mut fields: Vec<StatFields> = Vec::with_capacity(entries.len());
for entry in entries {
let metadata: std::fs::Metadata = match cursor.metadata(entry.path.as_slice()) {
Ok(found) => found,
Err(reason) if reason.ends_with("is not a plain path") => return Err(CleanRefusal::unreadable(reason)),
Err(reason) => return Err(CleanRefusal::new(CleanRefusalKind::FileChanged, reason)),
};
if let Some(refusal) = stat_finding(entry, &metadata, index_seconds) {
return Err(refusal);
}
fields.push(fields_of(&metadata));
}
return Ok(fields);
}
pub fn stat_fields_beneath(root: &std::fs::File, path: &[u8]) -> Option<StatFields> {
return StatCursor::new(root).fields(path);
}
fn untracked_file(context: &GitContext, root: &Path) -> Result<Option<String>, CleanRefusal> {
let request: GitRequest = GitRequest {
cwd: root.to_path_buf(),
arguments: ["ls-files", "--others", "--exclude-standard", "--directory", "--no-empty-directory", "-z"].map(Into::into).to_vec(),
without_prefix: true,
..GitRequest::default()
};
let output = run_git(context, &request).map_err(|error| { return CleanRefusal::unreadable(format!("Git could not list untracked files ({error})")); })?;
if output.code != Some(0) {
return Err(CleanRefusal::unreadable(format!("Git could not list untracked files: {}", String::from_utf8_lossy(output.stderr.as_slice()).trim())));
}
return Ok(output.stdout.split(|byte: &u8| { return *byte == 0; }).find(|name: &&[u8]| { return !name.is_empty(); }).map(|name: &[u8]| { return String::from_utf8_lossy(name).into_owned(); }));
}
pub fn check_clean_tracked(root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, String> {
return check_tracked(root, git_dir, common_dir, None, false).map_err(|refusal: CleanRefusal| { return refusal.reason; });
}
pub fn check_clean_tracked_by_content(root: &Path, git_dir: &Path, common_dir: &Path, environment: &ConversionEnvironment, owned_index_lock: bool) -> Result<CleanSource, String> {
return check_tracked(root, git_dir, common_dir, Some(environment), owned_index_lock).map_err(|refusal: CleanRefusal| { return refusal.reason; });
}
struct State {
bytes: Vec<u8>,
index_seconds: u32,
hash_length: usize,
format: ObjectFormat,
entries: Vec<IndexEntry>,
stats: Vec<StatEntry>,
commit: String,
tree_id: Vec<u8>,
tree: Vec<FlatEntry>,
}
impl State {
fn attribute_blobs(&self) -> Vec<(Vec<u8>, Vec<u8>)> {
return self.tree
.iter()
.filter(|entry: &&FlatEntry| { return entry.path == b".gitattributes" || entry.path.ends_with(b"/.gitattributes"); })
.map(|entry: &FlatEntry| { return (entry.path.clone(), entry.id.clone()); })
.collect();
}
fn into_source(self, index_bytes: Vec<u8>) -> CleanSource {
let attribute_blobs: Vec<(Vec<u8>, Vec<u8>)> = self.attribute_blobs();
return CleanSource { head_commit: self.commit, head_tree: self.tree_id, tracked: self.entries.len(), index_seconds: self.index_seconds, hash_length: self.hash_length, attribute_blobs, index_bytes };
}
}
fn read_state(git_dir: &Path, common_dir: &Path) -> Result<State, CleanRefusal> {
let hash_length: usize = repository_hash_length(common_dir).ok_or_else(|| { return CleanRefusal::unreadable("the repository configuration is unreadable"); })?;
let format: ObjectFormat = if hash_length == 32 { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
let index_path = git_dir.join("index");
let unreadable = |error: std::io::Error| -> CleanRefusal { return CleanRefusal::unreadable(format!("the index is unreadable ({error})")); };
let mut index_file: std::fs::File = std::fs::File::open(index_path.as_path()).map_err(unreadable)?;
let index_seconds: u32 = index_file.metadata().map_err(unreadable)?.mtime() as u32;
let mut bytes: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut index_file, &mut bytes).map_err(unreadable)?;
let entries: Vec<IndexEntry> = parse_index(bytes.as_slice(), format).ok_or_else(|| { return CleanRefusal::unreadable("the index has a layout the reader does not know"); })?;
let stats = stored_stats(bytes.as_slice(), hash_length).ok_or_else(|| { return CleanRefusal::unreadable("the index entries have no plain layout"); })?;
let commit: String = head_commit(git_dir, common_dir, format).ok_or_else(|| { return CleanRefusal::unreadable("HEAD is not a plain ref to a commit"); })?;
let database: ObjectDatabase = ObjectDatabase::open(common_dir, None, format).ok_or_else(|| { return CleanRefusal::unreadable("the object database has a layout the reader does not know"); })?;
let commit_name: Vec<u8> = crate::repo_read_object::from_hex(commit.as_bytes(), format).ok_or_else(|| { return CleanRefusal::unreadable("HEAD names no object"); })?;
let commit_fields = parse_commit(database.read(commit_name.as_slice()).ok_or_else(|| { return CleanRefusal::unreadable("the commit of HEAD is unreadable"); })?.bytes.as_slice(), format)
.ok_or_else(|| { return CleanRefusal::unreadable("the commit of HEAD has a layout the reader does not know"); })?;
let tree: Vec<FlatEntry> = flatten_tree(&database, commit_fields.tree.as_slice(), format).ok_or_else(|| { return CleanRefusal::unreadable("the tree of HEAD is unreadable"); })?;
return Ok(State { bytes, index_seconds, hash_length, format, entries, stats, commit, tree_id: commit_fields.tree, tree });
}
fn check_tracked(root: &Path, git_dir: &Path, common_dir: &Path, environment: Option<&ConversionEnvironment>, owned_index_lock: bool) -> Result<CleanSource, CleanRefusal> {
if let Some(name) = operation_in_progress(git_dir, owned_index_lock) {
return Err(CleanRefusal::new(CleanRefusalKind::OperationInProgress(name), format!("{name} exists in {}", git_dir.display())));
}
let state: State = crate::add_timing::step("readIndexAndTree", || { return read_state(git_dir, common_dir); })?;
if let Some(refusal) = crate::add_timing::step("indexAgainstTree", || { return index_differs_from_tree(state.entries.as_slice(), state.tree.as_slice()); }) {
return Err(refusal);
}
let differs: Option<CleanRefusal> = match environment {
None => crate::add_timing::step("walkSource", || { return tree_refusal(root, state.stats.as_slice(), state.index_seconds); }),
Some(found) => tree_differs_from_index_by_content(&ContentTarget { root, git_dir, common_dir, environment: found, format: state.format }, state.stats.as_slice(), state.entries.as_slice(), state.index_seconds)
.map(|reason: String| { return CleanRefusal::new(CleanRefusalKind::FileChanged, reason); }),
};
if let Some(refusal) = differs {
return Err(refusal);
}
let bytes: Vec<u8> = state.bytes.clone();
return Ok(state.into_source(bytes));
}
fn entry_a_reset_cannot_undo(state: &State) -> Option<CleanRefusal> {
for staged in &state.entries {
let shown: String = String::from_utf8_lossy(&staged.path).into_owned();
if staged.stage != 0 || staged.extended != 0 {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("the index entry {shown} is unmerged, intent-to-add or skip-worktree")));
}
if staged.mode == MODE_GITLINK {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{shown} is a submodule")));
}
}
if let Some(entry) = state.tree.iter().find(|entry: &&FlatEntry| { return entry.mode == MODE_GITLINK; }) {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule in HEAD", String::from_utf8_lossy(&entry.path))));
}
return state.stats.iter().find(|entry: &&StatEntry| { return entry.flags & ASSUME_VALID != 0; }).map(|entry: &StatEntry| {
return CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is marked assume-unchanged", String::from_utf8_lossy(&entry.path)));
});
}
pub fn check_clean(context: &GitContext, root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, CleanRefusal> {
let (tracked, untracked): (Result<CleanSource, CleanRefusal>, Result<Option<String>, CleanRefusal>) = std::thread::scope(|scope| {
let scan = scope.spawn(|| { return crate::add_timing::work("untrackedScan", || { return untracked_file(context, root); }); });
let tracked = check_tracked(root, git_dir, common_dir, None, false);
let scanned = scan.join().unwrap_or_else(|_panic| { return Err(CleanRefusal::unreadable("the scan for untracked files stopped unexpectedly")); });
return (tracked, scanned);
});
let clean: CleanSource = tracked?;
if let Some(name) = untracked? {
return Err(CleanRefusal::new(CleanRefusalKind::Untracked, format!("{name} is untracked and not ignored")));
}
return Ok(clean);
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DirtySource {
pub source: CleanSource,
pub why: CleanRefusal,
}
pub fn dirty_source(git_dir: &Path, common_dir: &Path, why: CleanRefusal) -> Result<DirtySource, CleanRefusal> {
if !why.kind.restorable() {
return Err(why);
}
let state: State = read_state(git_dir, common_dir)?;
if let Some(refusal) = entry_a_reset_cannot_undo(&state) {
return Err(refusal);
}
let read: Vec<u8> = state.bytes.clone();
return Ok(DirtySource { source: state.into_source(read), why });
}
#[cfg(test)]
#[path = "worktree_clean_tests.rs"]
mod tests;