use crate::add_timing::{begin_step, begin_work, end_step, end_work};
use crate::side_by_side_threads::run_beside_caller;
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::worktree_clean_content::ConversionEnvironment;
pub use crate::worktree_clean_stat_verdicts::{CleanRefusal, CleanRefusalKind, StatEntry, StatVerdict, metadata_differs_from_entry, metadata_verdict};
use crate::worktree_clean_stat_verdicts::{ASSUME_VALID, file_changed};
pub use crate::worktree_clean_tree_walk::{ContentTarget, StatCursor, stat_fields_beneath, tree_differs_from_index, tree_differs_from_index_beneath, tree_fields_beneath};
use crate::worktree_clean_tree_walk::{tree_differs_from_index_by_content, tree_refusal};
use crate::subvolume_index::{StoredStat, be32, walk_entries};
use crate::subvolume_index_files::repository_hash_length;
use crate::transaction_git::{GitContext, GitRequest, run_git};
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"];
#[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 or_unreadable<T>(found: Option<T>, reason: &'static str) -> Result<T, CleanRefusal> {
return match found {
Some(value) => Ok(value),
None => Err(CleanRefusal::unreadable(reason)),
};
}
fn index_unreadable(error: std::io::Error) -> CleanRefusal {
return CleanRefusal::unreadable(format!("the index is unreadable ({error})"));
}
fn is_nul_byte(byte: &u8) -> bool {
return *byte == 0;
}
fn operation_in_progress(git_dir: &Path, owned_index_lock: bool) -> Option<&'static str> {
for name in IN_PROGRESS.iter().copied() {
if owned_index_lock && name == "index.lock" {
continue;
}
if git_dir.join(name).symlink_metadata().is_ok() {
return Some(name);
}
}
return None;
}
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;
}
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 = match run_git(context, &request) {
Ok(finished) => finished,
Err(error) => return Err(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())));
}
for name in output.stdout.split(is_nul_byte) {
if !name.is_empty() {
return Ok(Some(String::from_utf8_lossy(name).into_owned()));
}
}
return Ok(None);
}
pub fn check_clean_tracked(root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, String> {
return match check_tracked(root, git_dir, common_dir, None, false) {
Ok(source) => Ok(source),
Err(refusal) => Err(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 match check_tracked(root, git_dir, common_dir, Some(environment), owned_index_lock) {
Ok(source) => Ok(source),
Err(refusal) => Err(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>)> {
let mut blobs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for entry in &self.tree {
if entry.path == b".gitattributes" || entry.path.ends_with(b"/.gitattributes") {
blobs.push((entry.path.clone(), entry.id.clone()));
}
}
return blobs;
}
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 = or_unreadable(repository_hash_length(common_dir), "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 mut index_file: std::fs::File = std::fs::File::open(index_path.as_path()).map_err(index_unreadable)?;
let index_seconds: u32 = index_file.metadata().map_err(index_unreadable)?.mtime() as u32;
let mut bytes: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut index_file, &mut bytes).map_err(index_unreadable)?;
let entries: Vec<IndexEntry> = or_unreadable(parse_index(bytes.as_slice(), format), "the index has a layout the reader does not know")?;
let stats = or_unreadable(stored_stats(bytes.as_slice(), hash_length), "the index entries have no plain layout")?;
let commit: String = or_unreadable(head_commit(git_dir, common_dir, format), "HEAD is not a plain ref to a commit")?;
let database: ObjectDatabase = or_unreadable(ObjectDatabase::open(common_dir, None, format), "the object database has a layout the reader does not know")?;
let commit_name: Vec<u8> = or_unreadable(crate::repo_read_object::from_hex(commit.as_bytes(), format), "HEAD names no object")?;
let commit_object = or_unreadable(database.read(commit_name.as_slice()), "the commit of HEAD is unreadable")?;
let commit_fields = or_unreadable(parse_commit(commit_object.bytes.as_slice(), format), "the commit of HEAD has a layout the reader does not know")?;
let tree: Vec<FlatEntry> = or_unreadable(flatten_tree(&database, commit_fields.tree.as_slice(), format), "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 read_index_and_tree_entered: bool = begin_step("readIndexAndTree");
let read_index_and_tree_outcome = read_state(git_dir, common_dir);
end_step(read_index_and_tree_entered);
let state: State = read_index_and_tree_outcome?;
let index_against_tree_entered: bool = begin_step("indexAgainstTree");
let index_against_tree_outcome = index_differs_from_tree(state.entries.as_slice(), state.tree.as_slice());
end_step(index_against_tree_entered);
if let Some(refusal) = index_against_tree_outcome {
return Err(refusal);
}
let differs: Option<CleanRefusal> = match environment {
None => {
let walk_source_entered: bool = begin_step("walkSource");
let walked: Option<CleanRefusal> = tree_refusal(root, state.stats.as_slice(), state.index_seconds);
end_step(walk_source_entered);
walked
}
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(file_changed),
};
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")));
}
}
for entry in &state.tree {
if entry.mode == MODE_GITLINK {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is a submodule in HEAD", String::from_utf8_lossy(&entry.path))));
}
}
for entry in &state.stats {
if entry.flags & ASSUME_VALID != 0 {
return Some(CleanRefusal::new(CleanRefusalKind::NotPlain, format!("{} is marked assume-unchanged", String::from_utf8_lossy(&entry.path))));
}
}
return None;
}
fn scan_for_untracked_file((context, root): (&GitContext, &Path)) -> Result<Option<String>, CleanRefusal> {
let began: std::time::Instant = begin_work();
let scanned: Result<Option<String>, CleanRefusal> = untracked_file(context, root);
end_work("untrackedScan", began);
return scanned;
}
fn check_tracked_files((root, git_dir, common_dir): (&Path, &Path, &Path)) -> Result<CleanSource, CleanRefusal> {
return check_tracked(root, git_dir, common_dir, None, false);
}
pub fn check_clean(context: &GitContext, root: &Path, git_dir: &Path, common_dir: &Path) -> Result<CleanSource, CleanRefusal> {
let (scan_joined, tracked): (std::thread::Result<Result<Option<String>, CleanRefusal>>, Result<CleanSource, CleanRefusal>) =
run_beside_caller(((context, root), scan_for_untracked_file), ((root, git_dir, common_dir), check_tracked_files));
let untracked: Result<Option<String>, CleanRefusal> = match scan_joined {
Ok(found) => found,
Err(_panic) => Err(CleanRefusal::unreadable("the scan for untracked files stopped unexpectedly")),
};
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;